+28
-10
package-lock.json
+28
-10
package-lock.json
···
17
17
"@icons-pack/react-simple-icons": "^13.8.0",
18
18
"@neondatabase/serverless": "^1.0.2",
19
19
"@netlify/functions": "^4.2.7",
20
+
"@tanstack/react-virtual": "^3.13.13",
20
21
"actor-typeahead": "^0.1.2",
21
22
"cookie": "^1.0.2",
22
23
"jose": "^6.1.0",
···
59
60
"resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.1.0.tgz",
60
61
"integrity": "sha512-6vRvRqJatDB+JUQsb+UswYmtBGQnSZcqC3a2y6H5DB/v5KcIh+6nFFtc17G0+3W9rxdk7k9M4KkgkdKf/YDNoQ==",
61
62
"license": "0BSD",
62
-
"peer": true,
63
63
"dependencies": {
64
64
"@atcute/lexicons": "^1.1.1",
65
65
"@badrap/valita": "^0.4.5"
···
412
412
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
413
413
"dev": true,
414
414
"license": "MIT",
415
-
"peer": true,
416
415
"dependencies": {
417
416
"@babel/code-frame": "^7.27.1",
418
417
"@babel/generator": "^7.28.3",
···
2576
2575
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
2577
2576
"dev": true,
2578
2577
"license": "MIT",
2579
-
"peer": true,
2580
2578
"dependencies": {
2581
2579
"@babel/core": "^7.21.3",
2582
2580
"@svgr/babel-preset": "8.1.0",
···
2633
2631
"@svgr/core": "*"
2634
2632
}
2635
2633
},
2634
+
"node_modules/@tanstack/react-virtual": {
2635
+
"version": "3.13.13",
2636
+
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz",
2637
+
"integrity": "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==",
2638
+
"license": "MIT",
2639
+
"dependencies": {
2640
+
"@tanstack/virtual-core": "3.13.13"
2641
+
},
2642
+
"funding": {
2643
+
"type": "github",
2644
+
"url": "https://github.com/sponsors/tannerlinsley"
2645
+
},
2646
+
"peerDependencies": {
2647
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
2648
+
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
2649
+
}
2650
+
},
2651
+
"node_modules/@tanstack/virtual-core": {
2652
+
"version": "3.13.13",
2653
+
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz",
2654
+
"integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==",
2655
+
"license": "MIT",
2656
+
"funding": {
2657
+
"type": "github",
2658
+
"url": "https://github.com/sponsors/tannerlinsley"
2659
+
}
2660
+
},
2636
2661
"node_modules/@types/babel__core": {
2637
2662
"version": "7.20.5",
2638
2663
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
···
2726
2751
"integrity": "sha512-ukd93VGzaNPMAUPy0gRDSC57UuQbnH9Kussp7HBjM06YFi9uZTFhOvMSO2OKqXm1rSgzOE+pVx1k1PYHGwlc8Q==",
2727
2752
"dev": true,
2728
2753
"license": "MIT",
2729
-
"peer": true,
2730
2754
"dependencies": {
2731
2755
"csstype": "^3.0.2"
2732
2756
}
···
3080
3104
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
3081
3105
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
3082
3106
"license": "MIT",
3083
-
"peer": true,
3084
3107
"bin": {
3085
3108
"acorn": "bin/acorn"
3086
3109
},
···
3498
3521
}
3499
3522
],
3500
3523
"license": "MIT",
3501
-
"peer": true,
3502
3524
"dependencies": {
3503
3525
"baseline-browser-mapping": "^2.8.3",
3504
3526
"caniuse-lite": "^1.0.30001741",
···
6324
6346
}
6325
6347
],
6326
6348
"license": "MIT",
6327
-
"peer": true,
6328
6349
"dependencies": {
6329
6350
"nanoid": "^3.3.11",
6330
6351
"picocolors": "^1.1.1",
···
6624
6645
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
6625
6646
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
6626
6647
"license": "MIT",
6627
-
"peer": true,
6628
6648
"dependencies": {
6629
6649
"loose-envify": "^1.1.0"
6630
6650
},
···
7500
7520
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
7501
7521
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
7502
7522
"license": "Apache-2.0",
7503
-
"peer": true,
7504
7523
"bin": {
7505
7524
"tsc": "bin/tsc",
7506
7525
"tsserver": "bin/tsserver"
···
7651
7670
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
7652
7671
"dev": true,
7653
7672
"license": "MIT",
7654
-
"peer": true,
7655
7673
"dependencies": {
7656
7674
"esbuild": "^0.21.3",
7657
7675
"postcss": "^8.4.43",
+1
package.json
+1
package.json
+143
-136
src/App.tsx
+143
-136
src/App.tsx
···
1
-
import { useState, useEffect, useCallback } from "react";
1
+
import React, { useState, useEffect, useCallback, Suspense, lazy } from "react";
2
2
import { ArrowRight } from "lucide-react";
3
-
import LoginPage from "./pages/Login";
4
-
import HomePage from "./pages/Home";
5
-
import LoadingPage from "./pages/Loading";
6
-
import ResultsPage from "./pages/Results";
7
3
import { useAuth } from "./hooks/useAuth";
8
4
import { useSearch } from "./hooks/useSearch";
9
5
import { useFollow } from "./hooks/useFollows";
···
12
8
import { useNotifications } from "./hooks/useNotifications";
13
9
import Firefly from "./components/Firefly";
14
10
import NotificationContainer from "./components/common/NotificationContainer";
11
+
import ErrorBoundary from "./components/common/ErrorBoundary";
12
+
import { SearchResultSkeleton } from "./components/common/LoadingSkeleton";
15
13
import { DEFAULT_SETTINGS } from "./types/settings";
16
14
import type { UserSettings, SearchResult } from "./types";
17
15
import { apiClient } from "./lib/api/client";
18
16
import { ATPROTO_APPS } from "./config/atprotoApps";
17
+
import { useSettings } from "./contexts/SettingsContext";
18
+
19
+
// Lazy load page components
20
+
const LoginPage = lazy(() => import("./pages/Login"));
21
+
const HomePage = lazy(() => import("./pages/Home"));
22
+
const LoadingPage = lazy(() => import("./pages/Loading"));
23
+
const ResultsPage = lazy(() => import("./pages/Results"));
24
+
25
+
// Loading fallback component
26
+
const PageLoader: React.FC = () => (
27
+
<div className="p-6 max-w-md mx-auto mt-8">
28
+
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4">
29
+
<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">
30
+
<ArrowRight className="w-8 h-8 text-white animate-pulse" />
31
+
</div>
32
+
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
33
+
Loading...
34
+
</h2>
35
+
</div>
36
+
</div>
37
+
);
19
38
20
39
export default function App() {
21
40
// Auth hook
···
43
62
const [savedUploads, setSavedUploads] = useState<Set<string>>(new Set());
44
63
45
64
// Settings state
46
-
const [userSettings, setUserSettings] = useState<UserSettings>(() => {
47
-
const saved = localStorage.getItem("atlast_settings");
48
-
return saved ? JSON.parse(saved) : DEFAULT_SETTINGS;
49
-
});
50
-
51
-
// Save settings to localStorage whenever they change
52
-
useEffect(() => {
53
-
localStorage.setItem("atlast_settings", JSON.stringify(userSettings));
54
-
}, [userSettings]);
55
-
56
-
const handleSettingsUpdate = useCallback(
57
-
(newSettings: Partial<UserSettings>) => {
58
-
setUserSettings((prev) => ({ ...prev, ...newSettings }));
59
-
},
60
-
[],
61
-
);
65
+
const { settings: userSettings, updateSettings: handleSettingsUpdate } =
66
+
useSettings();
62
67
63
68
// Search hook
64
69
const {
···
229
234
}, [logout, setSearchResults, success, error]);
230
235
231
236
return (
232
-
<div className="min-h-screen relative overflow-hidden">
233
-
{/* Notification Container */}
234
-
<NotificationContainer
235
-
notifications={notifications}
236
-
onRemove={removeNotification}
237
-
/>
237
+
<ErrorBoundary>
238
+
<div className="min-h-screen relative overflow-hidden">
239
+
{/* Notification Container */}
240
+
<NotificationContainer
241
+
notifications={notifications}
242
+
onRemove={removeNotification}
243
+
/>
244
+
245
+
{/* Firefly particles - only render if motion not reduced */}
246
+
{!reducedMotion && (
247
+
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
248
+
{[...Array(15)].map((_, i) => (
249
+
<Firefly
250
+
key={i}
251
+
delay={i * 0.5}
252
+
duration={3 + Math.random() * 2}
253
+
/>
254
+
))}
255
+
</div>
256
+
)}
238
257
239
-
{/* Firefly particles - only render if motion not reduced */}
240
-
{!reducedMotion && (
241
-
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
242
-
{[...Array(15)].map((_, i) => (
243
-
<Firefly key={i} delay={i * 0.5} duration={3 + Math.random() * 2} />
244
-
))}
258
+
{/* Status message for screen readers */}
259
+
<div
260
+
role="status"
261
+
aria-live="polite"
262
+
aria-atomic="true"
263
+
className="sr-only"
264
+
>
265
+
{statusMessage}
245
266
</div>
246
-
)}
247
267
248
-
{/* Status message for screen readers */}
249
-
<div
250
-
role="status"
251
-
aria-live="polite"
252
-
aria-atomic="true"
253
-
className="sr-only"
254
-
>
255
-
{statusMessage}
256
-
</div>
268
+
{/* Skip to main content link */}
269
+
<a
270
+
href="#main-content"
271
+
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"
272
+
>
273
+
Skip to main content
274
+
</a>
257
275
258
-
{/* Skip to main content link */}
259
-
<a
260
-
href="#main-content"
261
-
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"
262
-
>
263
-
Skip to main content
264
-
</a>
276
+
<main id="main-content">
277
+
<Suspense fallback={<PageLoader />}>
278
+
{/* Checking Session */}
279
+
{currentStep === "checking" && <PageLoader />}
265
280
266
-
<main id="main-content">
267
-
{/* Checking Session */}
268
-
{currentStep === "checking" && (
269
-
<div className="p-6 max-w-md mx-auto mt-8">
270
-
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4">
271
-
<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">
272
-
<ArrowRight className="w-8 h-8 text-white animate-pulse" />
273
-
</div>
274
-
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
275
-
Loading...
276
-
</h2>
277
-
<p className="text-gray-600 dark:text-gray-300">
278
-
Checking your session
279
-
</p>
280
-
</div>
281
-
</div>
282
-
)}
281
+
{/* Login Page */}
282
+
{currentStep === "login" && (
283
+
<ErrorBoundary fallbackType="inline">
284
+
<LoginPage
285
+
onSubmit={handleLogin}
286
+
session={session}
287
+
onNavigate={setCurrentStep}
288
+
reducedMotion={reducedMotion}
289
+
/>
290
+
</ErrorBoundary>
291
+
)}
283
292
284
-
{/* Login Page */}
285
-
{currentStep === "login" && (
286
-
<LoginPage
287
-
onSubmit={handleLogin}
288
-
session={session}
289
-
onNavigate={setCurrentStep}
290
-
reducedMotion={reducedMotion}
291
-
/>
292
-
)}
293
+
{/* Home/Dashboard Page */}
294
+
{currentStep === "home" && (
295
+
<ErrorBoundary fallbackType="inline">
296
+
<HomePage
297
+
session={session}
298
+
onLogout={handleLogout}
299
+
onNavigate={setCurrentStep}
300
+
onFileUpload={processFileUpload}
301
+
onLoadUpload={handleLoadUpload}
302
+
currentStep={currentStep}
303
+
reducedMotion={reducedMotion}
304
+
isDark={isDark}
305
+
onToggleTheme={toggleTheme}
306
+
onToggleMotion={toggleMotion}
307
+
userSettings={userSettings}
308
+
onSettingsUpdate={handleSettingsUpdate}
309
+
/>
310
+
</ErrorBoundary>
311
+
)}
293
312
294
-
{/* Home/Dashboard Page */}
295
-
{currentStep === "home" && (
296
-
<HomePage
297
-
session={session}
298
-
onLogout={handleLogout}
299
-
onNavigate={setCurrentStep}
300
-
onFileUpload={processFileUpload}
301
-
onLoadUpload={handleLoadUpload}
302
-
currentStep={currentStep}
303
-
reducedMotion={reducedMotion}
304
-
isDark={isDark}
305
-
onToggleTheme={toggleTheme}
306
-
onToggleMotion={toggleMotion}
307
-
userSettings={userSettings}
308
-
onSettingsUpdate={handleSettingsUpdate}
309
-
/>
310
-
)}
313
+
{/* Loading Page */}
314
+
{currentStep === "loading" && (
315
+
<ErrorBoundary fallbackType="inline">
316
+
<LoadingPage
317
+
session={session}
318
+
onLogout={handleLogout}
319
+
onNavigate={setCurrentStep}
320
+
searchProgress={searchProgress}
321
+
currentStep={currentStep}
322
+
sourcePlatform={currentPlatform}
323
+
isDark={isDark}
324
+
reducedMotion={reducedMotion}
325
+
onToggleTheme={toggleTheme}
326
+
onToggleMotion={toggleMotion}
327
+
/>
328
+
</ErrorBoundary>
329
+
)}
311
330
312
-
{/* Loading Page */}
313
-
{currentStep === "loading" && (
314
-
<LoadingPage
315
-
session={session}
316
-
onLogout={handleLogout}
317
-
onNavigate={setCurrentStep}
318
-
searchProgress={searchProgress}
319
-
currentStep={currentStep}
320
-
sourcePlatform={currentPlatform}
321
-
isDark={isDark}
322
-
reducedMotion={reducedMotion}
323
-
onToggleTheme={toggleTheme}
324
-
onToggleMotion={toggleMotion}
325
-
/>
326
-
)}
327
-
328
-
{/* Results Page */}
329
-
{currentStep === "results" && (
330
-
<ResultsPage
331
-
session={session}
332
-
onLogout={handleLogout}
333
-
onNavigate={setCurrentStep}
334
-
searchResults={searchResults}
335
-
expandedResults={expandedResults}
336
-
onToggleExpand={toggleExpandResult}
337
-
onToggleMatchSelection={toggleMatchSelection}
338
-
onSelectAll={() => selectAllMatches(setStatusMessage)}
339
-
onDeselectAll={() => deselectAllMatches(setStatusMessage)}
340
-
onFollowSelected={() => followSelectedUsers(setStatusMessage)}
341
-
totalSelected={totalSelected}
342
-
totalFound={totalFound}
343
-
isFollowing={isFollowing}
344
-
currentStep={currentStep}
345
-
sourcePlatform={currentPlatform}
346
-
destinationAppId={currentDestinationAppId}
347
-
reducedMotion={reducedMotion}
348
-
isDark={isDark}
349
-
onToggleTheme={toggleTheme}
350
-
onToggleMotion={toggleMotion}
351
-
/>
352
-
)}
353
-
</main>
354
-
</div>
331
+
{/* Results Page */}
332
+
{currentStep === "results" && (
333
+
<ErrorBoundary fallbackType="inline">
334
+
<ResultsPage
335
+
session={session}
336
+
onLogout={handleLogout}
337
+
onNavigate={setCurrentStep}
338
+
searchResults={searchResults}
339
+
expandedResults={expandedResults}
340
+
onToggleExpand={toggleExpandResult}
341
+
onToggleMatchSelection={toggleMatchSelection}
342
+
onSelectAll={() => selectAllMatches(setStatusMessage)}
343
+
onDeselectAll={() => deselectAllMatches(setStatusMessage)}
344
+
onFollowSelected={() => followSelectedUsers(setStatusMessage)}
345
+
totalSelected={totalSelected}
346
+
totalFound={totalFound}
347
+
isFollowing={isFollowing}
348
+
currentStep={currentStep}
349
+
sourcePlatform={currentPlatform}
350
+
destinationAppId={currentDestinationAppId}
351
+
reducedMotion={reducedMotion}
352
+
isDark={isDark}
353
+
onToggleTheme={toggleTheme}
354
+
onToggleMotion={toggleMotion}
355
+
/>
356
+
</ErrorBoundary>
357
+
)}
358
+
</Suspense>
359
+
</main>
360
+
</div>
361
+
</ErrorBoundary>
355
362
);
356
363
}
+3
-11
src/components/HistoryTab.tsx
+3
-11
src/components/HistoryTab.tsx
···
3
3
import type { Upload as UploadType } from "../types";
4
4
import FaviconIcon from "../components/FaviconIcon";
5
5
import type { UserSettings } from "../types/settings";
6
+
import { UploadHistorySkeleton } from "./common/LoadingSkeleton";
6
7
import { getPlatformColor } from "../lib/utils/platform";
7
8
import { formatRelativeTime } from "../lib/utils/date";
8
9
···
84
85
)}
85
86
86
87
{isLoading ? (
87
-
<div className="space-y-6">
88
+
<div className="space-y-3">
88
89
{[...Array(3)].map((_, i) => (
89
-
<div
90
-
key={i}
91
-
className="animate-pulse flex items-center space-x-4 p-4 bg-purple-100/50 dark:bg-slate-900/50 rounded-xl border-2 border-purple-500/30 dark:border-cyan-500/30"
92
-
>
93
-
<div className="w-12 h-12 bg-purple-200 dark:bg-slate-600 rounded-xl" />
94
-
<div className="flex-1 space-y-2">
95
-
<div className="h-4 bg-purple-200 dark:bg-slate-600 rounded w-3/4" />
96
-
<div className="h-3 bg-purple-200 dark:bg-slate-600 rounded w-1/2" />
97
-
</div>
98
-
</div>
90
+
<UploadHistorySkeleton key={i} />
99
91
))}
100
92
</div>
101
93
) : uploads.length === 0 ? (
+78
src/components/VirtualizedResultsList.tsx
+78
src/components/VirtualizedResultsList.tsx
···
1
+
import React, { useRef } from "react";
2
+
import { useVirtualizer } from "@tanstack/react-virtual";
3
+
import SearchResultCard from "./SearchResultCard";
4
+
import type { SearchResult } from "../types";
5
+
import type { AtprotoAppId } from "../types/settings";
6
+
7
+
interface VirtualizedResultsListProps {
8
+
results: SearchResult[];
9
+
expandedResults: Set<number>;
10
+
onToggleExpand: (index: number) => void;
11
+
onToggleMatchSelection: (resultIndex: number, did: string) => void;
12
+
sourcePlatform: string;
13
+
destinationAppId: AtprotoAppId;
14
+
}
15
+
16
+
const VirtualizedResultsList: React.FC<VirtualizedResultsListProps> = ({
17
+
results,
18
+
expandedResults,
19
+
onToggleExpand,
20
+
onToggleMatchSelection,
21
+
sourcePlatform,
22
+
destinationAppId,
23
+
}) => {
24
+
const parentRef = useRef<HTMLDivElement>(null);
25
+
26
+
const virtualizer = useVirtualizer({
27
+
count: results.length,
28
+
getScrollElement: () => parentRef.current,
29
+
estimateSize: () => 200, // Estimated height per item
30
+
overscan: 5, // Render 5 extra items above/below viewport
31
+
});
32
+
33
+
return (
34
+
<div ref={parentRef} className="h-full overflow-auto">
35
+
<div
36
+
style={{
37
+
height: `${virtualizer.getTotalSize()}px`,
38
+
width: "100%",
39
+
position: "relative",
40
+
}}
41
+
>
42
+
{virtualizer.getVirtualItems().map((virtualItem) => {
43
+
const result = results[virtualItem.index];
44
+
45
+
return (
46
+
<div
47
+
key={virtualItem.key}
48
+
style={{
49
+
position: "absolute",
50
+
top: 0,
51
+
left: 0,
52
+
width: "100%",
53
+
height: `${virtualItem.size}px`,
54
+
transform: `translateY(${virtualItem.start}px)`,
55
+
}}
56
+
>
57
+
<div className="pb-4">
58
+
<SearchResultCard
59
+
result={result}
60
+
resultIndex={virtualItem.index}
61
+
isExpanded={expandedResults.has(virtualItem.index)}
62
+
onToggleExpand={() => onToggleExpand(virtualItem.index)}
63
+
onToggleMatchSelection={(did) =>
64
+
onToggleMatchSelection(virtualItem.index, did)
65
+
}
66
+
sourcePlatform={sourcePlatform}
67
+
destinationAppId={destinationAppId}
68
+
/>
69
+
</div>
70
+
</div>
71
+
);
72
+
})}
73
+
</div>
74
+
</div>
75
+
);
76
+
};
77
+
78
+
export default VirtualizedResultsList;
+138
src/components/common/ErrorBoundary.tsx
+138
src/components/common/ErrorBoundary.tsx
···
1
+
import React, { Component, ReactNode } from "react";
2
+
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
3
+
4
+
interface Props {
5
+
children: ReactNode;
6
+
fallbackType?: "full" | "inline";
7
+
onReset?: () => void;
8
+
}
9
+
10
+
interface State {
11
+
hasError: boolean;
12
+
error: Error | null;
13
+
errorInfo: React.ErrorInfo | null;
14
+
}
15
+
16
+
class ErrorBoundary extends Component<Props, State> {
17
+
constructor(props: Props) {
18
+
super(props);
19
+
this.state = {
20
+
hasError: false,
21
+
error: null,
22
+
errorInfo: null,
23
+
};
24
+
}
25
+
26
+
static getDerivedStateFromError(error: Error): Partial<State> {
27
+
return { hasError: true, error };
28
+
}
29
+
30
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
31
+
console.error("Error boundary caught error:", error, errorInfo);
32
+
this.setState({
33
+
error,
34
+
errorInfo,
35
+
});
36
+
}
37
+
38
+
handleReset = () => {
39
+
this.setState({
40
+
hasError: false,
41
+
error: null,
42
+
errorInfo: null,
43
+
});
44
+
if (this.props.onReset) {
45
+
this.props.onReset();
46
+
}
47
+
};
48
+
49
+
handleGoHome = () => {
50
+
window.location.href = "/";
51
+
};
52
+
53
+
render() {
54
+
if (this.state.hasError) {
55
+
const { fallbackType = "full" } = this.props;
56
+
57
+
if (fallbackType === "inline") {
58
+
return (
59
+
<div className="p-4 bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-xl">
60
+
<div className="flex items-start gap-3">
61
+
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
62
+
<div className="flex-1">
63
+
<h3 className="font-semibold text-red-900 dark:text-red-100 mb-1">
64
+
Something went wrong
65
+
</h3>
66
+
<p className="text-sm text-red-800 dark:text-red-200 mb-3">
67
+
{this.state.error?.message || "An unexpected error occurred"}
68
+
</p>
69
+
<button
70
+
onClick={this.handleReset}
71
+
className="inline-flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors"
72
+
>
73
+
<RefreshCw className="w-4 h-4" />
74
+
Try Again
75
+
</button>
76
+
</div>
77
+
</div>
78
+
</div>
79
+
);
80
+
}
81
+
82
+
// Full page error
83
+
return (
84
+
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-cyan-50 via-purple-50 to-pink-50 dark:from-indigo-950 dark:via-purple-900 dark:to-slate-900">
85
+
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-3xl shadow-2xl p-8 border-2 border-red-500">
86
+
<div className="flex justify-center mb-6">
87
+
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-2xl flex items-center justify-center">
88
+
<AlertTriangle className="w-8 h-8 text-red-600 dark:text-red-400" />
89
+
</div>
90
+
</div>
91
+
92
+
<h1 className="text-2xl font-bold text-center text-purple-950 dark:text-cyan-50 mb-2">
93
+
Oops! Something went wrong
94
+
</h1>
95
+
96
+
<p className="text-center text-purple-750 dark:text-cyan-250 mb-6">
97
+
We encountered an unexpected error. Don't worry, your data is
98
+
safe.
99
+
</p>
100
+
101
+
{process.env.NODE_ENV === "development" && this.state.error && (
102
+
<details className="mb-6 p-4 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs">
103
+
<summary className="cursor-pointer font-semibold text-purple-900 dark:text-cyan-100 mb-2">
104
+
Error Details (Dev Only)
105
+
</summary>
106
+
<pre className="overflow-auto text-red-600 dark:text-red-400">
107
+
{this.state.error.toString()}
108
+
{this.state.errorInfo?.componentStack}
109
+
</pre>
110
+
</details>
111
+
)}
112
+
113
+
<div className="flex gap-3">
114
+
<button
115
+
onClick={this.handleReset}
116
+
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-orange-600 hover:bg-orange-500 text-white font-semibold rounded-xl transition-colors"
117
+
>
118
+
<RefreshCw className="w-5 h-5" />
119
+
Try Again
120
+
</button>
121
+
<button
122
+
onClick={this.handleGoHome}
123
+
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-xl transition-colors"
124
+
>
125
+
<Home className="w-5 h-5" />
126
+
Go Home
127
+
</button>
128
+
</div>
129
+
</div>
130
+
</div>
131
+
);
132
+
}
133
+
134
+
return this.props.children;
135
+
}
136
+
}
137
+
138
+
export default ErrorBoundary;
+61
src/components/common/LoadingSkeleton.tsx
+61
src/components/common/LoadingSkeleton.tsx
···
1
+
import React from "react";
2
+
import Skeleton from "./Skeleton";
3
+
4
+
export const SearchResultSkeleton: React.FC = () => {
5
+
return (
6
+
<div className="bg-white/95 dark:bg-slate-800/95 backdrop-blur-xl rounded-2xl shadow-sm overflow-hidden border-2 border-slate-200 dark:border-slate-700">
7
+
{/* Source User Skeleton */}
8
+
<div className="p-4 bg-slate-50 dark:bg-slate-900/50 border-b-2 border-slate-200 dark:border-slate-700">
9
+
<div className="flex items-center space-x-3">
10
+
<Skeleton variant="circular" width={40} height={40} />
11
+
<div className="flex-1 space-y-2">
12
+
<Skeleton height={16} width="40%" />
13
+
<Skeleton height={12} width="30%" />
14
+
</div>
15
+
<Skeleton height={20} width={64} className="rounded-full" />
16
+
</div>
17
+
</div>
18
+
19
+
{/* Match Skeleton */}
20
+
<div className="p-4">
21
+
<div className="flex items-start space-x-3 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/10 border-2 border-amber-200 dark:border-amber-800/30">
22
+
<Skeleton variant="circular" width={48} height={48} />
23
+
<div className="flex-1 space-y-2">
24
+
<Skeleton height={16} width="75%" />
25
+
<Skeleton height={12} width="50%" />
26
+
<Skeleton height={12} width="100%" />
27
+
<div className="flex gap-2 mt-2">
28
+
<Skeleton height={20} width={80} className="rounded-full" />
29
+
<Skeleton height={20} width={100} className="rounded-full" />
30
+
</div>
31
+
</div>
32
+
<Skeleton width={80} height={32} className="rounded-full" />
33
+
</div>
34
+
</div>
35
+
</div>
36
+
);
37
+
};
38
+
39
+
export const UploadHistorySkeleton: React.FC = () => {
40
+
return (
41
+
<div className="flex items-center space-x-4 p-4 bg-purple-100/50 dark:bg-slate-900/50 rounded-xl border-2 border-purple-500/30 dark:border-cyan-500/30">
42
+
<Skeleton variant="rectangular" width={48} height={48} />
43
+
<div className="flex-1 space-y-2">
44
+
<Skeleton height={16} width="60%" />
45
+
<Skeleton height={12} width="40%" />
46
+
</div>
47
+
</div>
48
+
);
49
+
};
50
+
51
+
export const ProfileSkeleton: React.FC = () => {
52
+
return (
53
+
<div className="flex items-center space-x-3 p-3">
54
+
<Skeleton variant="circular" width={48} height={48} />
55
+
<div className="flex-1 space-y-2">
56
+
<Skeleton height={16} width="50%" />
57
+
<Skeleton height={12} width="70%" />
58
+
</div>
59
+
</div>
60
+
);
61
+
};
+41
src/components/common/Skeleton.tsx
+41
src/components/common/Skeleton.tsx
···
1
+
import React from "react";
2
+
3
+
interface SkeletonProps {
4
+
className?: string;
5
+
variant?: "text" | "circular" | "rectangular";
6
+
width?: string | number;
7
+
height?: string | number;
8
+
animate?: boolean;
9
+
}
10
+
11
+
const Skeleton: React.FC<SkeletonProps> = ({
12
+
className = "",
13
+
variant = "text",
14
+
width,
15
+
height,
16
+
animate = true,
17
+
}) => {
18
+
const baseClasses = "bg-slate-200 dark:bg-slate-700";
19
+
const animateClass = animate ? "animate-pulse" : "";
20
+
21
+
const variantClasses = {
22
+
text: "rounded",
23
+
circular: "rounded-full",
24
+
rectangular: "rounded-lg",
25
+
};
26
+
27
+
const style: React.CSSProperties = {};
28
+
if (width) style.width = typeof width === "number" ? `${width}px` : width;
29
+
if (height)
30
+
style.height = typeof height === "number" ? `${height}px` : height;
31
+
32
+
return (
33
+
<div
34
+
className={`${baseClasses} ${variantClasses[variant]} ${animateClass} ${className}`}
35
+
style={style}
36
+
aria-hidden="true"
37
+
/>
38
+
);
39
+
};
40
+
41
+
export default Skeleton;
+87
src/contexts/SettingsContext.tsx
+87
src/contexts/SettingsContext.tsx
···
1
+
import React, {
2
+
createContext,
3
+
useContext,
4
+
useState,
5
+
useEffect,
6
+
useCallback,
7
+
ReactNode,
8
+
} from "react";
9
+
import { DEFAULT_SETTINGS, UserSettings } from "../types/settings";
10
+
11
+
interface SettingsContextType {
12
+
settings: UserSettings;
13
+
updateSettings: (newSettings: Partial<UserSettings>) => void;
14
+
resetSettings: () => void;
15
+
isLoading: boolean;
16
+
}
17
+
18
+
const SettingsContext = createContext<SettingsContextType | undefined>(
19
+
undefined,
20
+
);
21
+
22
+
export const useSettings = (): SettingsContextType => {
23
+
const context = useContext(SettingsContext);
24
+
if (!context) {
25
+
throw new Error("useSettings must be used within a SettingsProvider");
26
+
}
27
+
return context;
28
+
};
29
+
30
+
interface SettingsProviderProps {
31
+
children: ReactNode;
32
+
}
33
+
34
+
export const SettingsProvider: React.FC<SettingsProviderProps> = ({
35
+
children,
36
+
}) => {
37
+
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS);
38
+
const [isLoading, setIsLoading] = useState(true);
39
+
40
+
// Load settings from localStorage on mount
41
+
useEffect(() => {
42
+
try {
43
+
const saved = localStorage.getItem("atlast_settings");
44
+
if (saved) {
45
+
const parsed = JSON.parse(saved);
46
+
setSettings(parsed);
47
+
}
48
+
} catch (error) {
49
+
console.error("Failed to load settings:", error);
50
+
} finally {
51
+
setIsLoading(false);
52
+
}
53
+
}, []);
54
+
55
+
// Save settings to localStorage whenever they change
56
+
useEffect(() => {
57
+
if (!isLoading) {
58
+
try {
59
+
localStorage.setItem("atlast_settings", JSON.stringify(settings));
60
+
} catch (error) {
61
+
console.error("Failed to save settings:", error);
62
+
}
63
+
}
64
+
}, [settings, isLoading]);
65
+
66
+
const updateSettings = useCallback((newSettings: Partial<UserSettings>) => {
67
+
setSettings((prev) => ({ ...prev, ...newSettings }));
68
+
}, []);
69
+
70
+
const resetSettings = useCallback(() => {
71
+
setSettings(DEFAULT_SETTINGS);
72
+
localStorage.removeItem("atlast_settings");
73
+
}, []);
74
+
75
+
const value: SettingsContextType = {
76
+
settings,
77
+
updateSettings,
78
+
resetSettings,
79
+
isLoading,
80
+
};
81
+
82
+
return (
83
+
<SettingsContext.Provider value={value}>
84
+
{children}
85
+
</SettingsContext.Provider>
86
+
);
87
+
};
+126
src/hooks/useFormValidation.ts
+126
src/hooks/useFormValidation.ts
···
1
+
import { useState, useCallback } from "react";
2
+
import { ValidationResult } from "../lib/validation";
3
+
4
+
interface FieldState {
5
+
value: string;
6
+
error: string | null;
7
+
touched: boolean;
8
+
}
9
+
10
+
type ValidationFunction = (value: string) => ValidationResult;
11
+
12
+
export function useFormValidation(initialValues: Record<string, string>) {
13
+
const [fields, setFields] = useState<Record<string, FieldState>>(() => {
14
+
const initial: Record<string, FieldState> = {};
15
+
Object.keys(initialValues).forEach((key) => {
16
+
initial[key] = {
17
+
value: initialValues[key],
18
+
error: null,
19
+
touched: false,
20
+
};
21
+
});
22
+
return initial;
23
+
});
24
+
25
+
const setValue = useCallback((fieldName: string, value: string) => {
26
+
setFields((prev) => ({
27
+
...prev,
28
+
[fieldName]: {
29
+
...prev[fieldName],
30
+
value,
31
+
},
32
+
}));
33
+
}, []);
34
+
35
+
const setError = useCallback((fieldName: string, error: string | null) => {
36
+
setFields((prev) => ({
37
+
...prev,
38
+
[fieldName]: {
39
+
...prev[fieldName],
40
+
error,
41
+
},
42
+
}));
43
+
}, []);
44
+
45
+
const setTouched = useCallback((fieldName: string) => {
46
+
setFields((prev) => ({
47
+
...prev,
48
+
[fieldName]: {
49
+
...prev[fieldName],
50
+
touched: true,
51
+
},
52
+
}));
53
+
}, []);
54
+
55
+
const validate = useCallback(
56
+
(fieldName: string, validationFn: ValidationFunction): boolean => {
57
+
const result = validationFn(fields[fieldName].value);
58
+
setFields((prev) => ({
59
+
...prev,
60
+
[fieldName]: {
61
+
...prev[fieldName],
62
+
error: result.error || null,
63
+
touched: true,
64
+
},
65
+
}));
66
+
return result.isValid;
67
+
},
68
+
[fields],
69
+
);
70
+
71
+
const validateAll = useCallback(
72
+
(validations: Record<string, ValidationFunction>): boolean => {
73
+
let isValid = true;
74
+
const newFields = { ...fields };
75
+
76
+
Object.keys(validations).forEach((fieldName) => {
77
+
const result = validations[fieldName](fields[fieldName].value);
78
+
newFields[fieldName] = {
79
+
...newFields[fieldName],
80
+
error: result.error || null,
81
+
touched: true,
82
+
};
83
+
if (!result.isValid) {
84
+
isValid = false;
85
+
}
86
+
});
87
+
88
+
setFields(newFields);
89
+
return isValid;
90
+
},
91
+
[fields],
92
+
);
93
+
94
+
const reset = useCallback(() => {
95
+
const resetFields: Record<string, FieldState> = {};
96
+
Object.keys(fields).forEach((key) => {
97
+
resetFields[key] = {
98
+
value: "",
99
+
error: null,
100
+
touched: false,
101
+
};
102
+
});
103
+
setFields(resetFields);
104
+
}, [fields]);
105
+
106
+
const getFieldProps = useCallback(
107
+
(fieldName: string) => ({
108
+
value: fields[fieldName]?.value || "",
109
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
110
+
setValue(fieldName, e.target.value),
111
+
onBlur: () => setTouched(fieldName),
112
+
}),
113
+
[fields, setValue, setTouched],
114
+
);
115
+
116
+
return {
117
+
fields,
118
+
setValue,
119
+
setError,
120
+
setTouched,
121
+
validate,
122
+
validateAll,
123
+
reset,
124
+
getFieldProps,
125
+
};
126
+
}
+141
src/lib/validation.ts
+141
src/lib/validation.ts
···
1
+
/**
2
+
* Validation utilities for forms
3
+
*/
4
+
5
+
export interface ValidationResult {
6
+
isValid: boolean;
7
+
error?: string;
8
+
}
9
+
10
+
/**
11
+
* Validate AT Protocol handle
12
+
*/
13
+
export function validateHandle(handle: string): ValidationResult {
14
+
const trimmed = handle.trim();
15
+
16
+
if (!trimmed) {
17
+
return {
18
+
isValid: false,
19
+
error: "Please enter your handle",
20
+
};
21
+
}
22
+
23
+
// Remove @ if user included it
24
+
const cleanHandle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
25
+
26
+
// Basic format validation
27
+
if (cleanHandle.length < 3) {
28
+
return {
29
+
isValid: false,
30
+
error: "Handle is too short",
31
+
};
32
+
}
33
+
34
+
// Check for valid characters (alphanumeric, dots, hyphens)
35
+
const validFormat = /^[a-zA-Z0-9.-]+$/;
36
+
if (!validFormat.test(cleanHandle)) {
37
+
return {
38
+
isValid: false,
39
+
error: "Handle contains invalid characters",
40
+
};
41
+
}
42
+
43
+
// Must contain at least one dot (domain required)
44
+
if (!cleanHandle.includes(".")) {
45
+
return {
46
+
isValid: false,
47
+
error: "Handle must include a domain (e.g., username.bsky.social)",
48
+
};
49
+
}
50
+
51
+
// Can't start or end with dot or hyphen
52
+
if (/^[.-]|[.-]$/.test(cleanHandle)) {
53
+
return {
54
+
isValid: false,
55
+
error: "Handle cannot start or end with . or -",
56
+
};
57
+
}
58
+
59
+
return { isValid: true };
60
+
}
61
+
62
+
/**
63
+
* Validate email format
64
+
*/
65
+
export function validateEmail(email: string): ValidationResult {
66
+
const trimmed = email.trim();
67
+
68
+
if (!trimmed) {
69
+
return {
70
+
isValid: false,
71
+
error: "Please enter your email",
72
+
};
73
+
}
74
+
75
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
76
+
if (!emailRegex.test(trimmed)) {
77
+
return {
78
+
isValid: false,
79
+
error: "Please enter a valid email address",
80
+
};
81
+
}
82
+
83
+
return { isValid: true };
84
+
}
85
+
86
+
/**
87
+
* Validate required field
88
+
*/
89
+
export function validateRequired(
90
+
value: string,
91
+
fieldName: string = "This field",
92
+
): ValidationResult {
93
+
const trimmed = value.trim();
94
+
95
+
if (!trimmed) {
96
+
return {
97
+
isValid: false,
98
+
error: `${fieldName} is required`,
99
+
};
100
+
}
101
+
102
+
return { isValid: true };
103
+
}
104
+
105
+
/**
106
+
* Validate minimum length
107
+
*/
108
+
export function validateMinLength(
109
+
value: string,
110
+
minLength: number,
111
+
fieldName: string = "This field",
112
+
): ValidationResult {
113
+
const trimmed = value.trim();
114
+
115
+
if (trimmed.length < minLength) {
116
+
return {
117
+
isValid: false,
118
+
error: `${fieldName} must be at least ${minLength} characters`,
119
+
};
120
+
}
121
+
122
+
return { isValid: true };
123
+
}
124
+
125
+
/**
126
+
* Validate maximum length
127
+
*/
128
+
export function validateMaxLength(
129
+
value: string,
130
+
maxLength: number,
131
+
fieldName: string = "This field",
132
+
): ValidationResult {
133
+
if (value.length > maxLength) {
134
+
return {
135
+
isValid: false,
136
+
error: `${fieldName} must be ${maxLength} characters or less`,
137
+
};
138
+
}
139
+
140
+
return { isValid: true };
141
+
}
+4
-1
src/main.tsx
+4
-1
src/main.tsx
···
1
1
import React from "react";
2
2
import ReactDOM from "react-dom/client";
3
3
import App from "./App";
4
+
import { SettingsProvider } from "./contexts/SettingsContext";
4
5
import "./index.css";
5
6
6
7
ReactDOM.createRoot(document.getElementById("root")!).render(
7
8
<React.StrictMode>
8
-
<App />
9
+
<SettingsProvider>
10
+
<App />
11
+
</SettingsProvider>
9
12
</React.StrictMode>,
10
13
);
+2
-30
src/pages/Loading.tsx
+2
-30
src/pages/Loading.tsx
···
1
1
import AppHeader from "../components/AppHeader";
2
+
import { SearchResultSkeleton } from "../components/common/LoadingSkeleton";
2
3
import { getPlatform } from "../lib/utils/platform";
3
4
4
5
interface atprotoSession {
···
148
149
{/* Skeleton Results - Matches layout of Results page */}
149
150
<div className="max-w-3xl mx-auto px-4 py-4 space-y-4">
150
151
{[...Array(8)].map((_, i) => (
151
-
<div
152
-
key={i}
153
-
className="bg-white/95 dark:bg-slate-800/95 backdrop-blur-xl rounded-2xl shadow-sm overflow-hidden animate-pulse border-2 border-slate-200 dark:border-slate-700"
154
-
>
155
-
{/* Source User Skeleton */}
156
-
<div className="p-4 bg-slate-50 dark:bg-slate-900/50 border-b-2 border-slate-200 dark:border-slate-700">
157
-
<div className="flex items-center space-x-3">
158
-
<div className="w-10 h-10 bg-slate-300 dark:bg-slate-600 rounded-full" />
159
-
<div className="flex-1 space-y-2">
160
-
<div className="h-4 bg-slate-300 dark:bg-slate-600 rounded w-32" />
161
-
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-24" />
162
-
</div>
163
-
<div className="h-5 w-16 bg-slate-300 dark:bg-slate-600 rounded-full" />
164
-
</div>
165
-
</div>
166
-
167
-
{/* Match Skeleton */}
168
-
<div className="p-4">
169
-
<div className="flex items-start space-x-3 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/10 border-2 border-amber-200 dark:border-amber-800/30">
170
-
<div className="w-12 h-12 bg-slate-300 dark:bg-slate-600 rounded-full flex-shrink-0" />
171
-
<div className="flex-1 space-y-2">
172
-
<div className="h-4 bg-slate-300 dark:bg-slate-600 rounded w-3/4" />
173
-
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2" />
174
-
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-full" />
175
-
<div className="h-5 w-20 bg-slate-300 dark:bg-slate-600 rounded-full mt-2" />
176
-
</div>
177
-
<div className="w-20 h-8 bg-slate-300 dark:bg-slate-600 rounded-full flex-shrink-0" />
178
-
</div>
179
-
</div>
180
-
</div>
152
+
<SearchResultSkeleton key={i} />
181
153
))}
182
154
</div>
183
155
</div>
+64
-15
src/pages/Login.tsx
+64
-15
src/pages/Login.tsx
···
1
-
import { useState, useRef } from "react";
1
+
import { useState, useRef, useEffect } from "react";
2
2
import "actor-typeahead";
3
-
import { Heart, Upload, Search, ArrowRight } from "lucide-react";
3
+
import { Heart, Upload, Search, ArrowRight, AlertCircle } from "lucide-react";
4
4
import FireflyLogo from "../assets/at-firefly-logo.svg?react";
5
+
import { useFormValidation } from "../hooks/useFormValidation";
6
+
import { validateHandle } from "../lib/validation";
5
7
6
8
interface LoginPageProps {
7
9
onSubmit: (handle: string) => void;
···
16
18
onNavigate,
17
19
reducedMotion = false,
18
20
}: LoginPageProps) {
19
-
const [handle, setHandle] = useState("");
20
21
const inputRef = useRef<HTMLInputElement>(null);
22
+
const [isSubmitting, setIsSubmitting] = useState(false);
21
23
22
-
const handleSubmit = (e: React.FormEvent) => {
24
+
const { fields, setValue, validate, getFieldProps } = useFormValidation({
25
+
handle: "",
26
+
});
27
+
28
+
const handleSubmit = async (e: React.FormEvent) => {
23
29
e.preventDefault();
24
-
// Get the value directly from the input instead of state
25
-
const currentHandle = inputRef.current?.value || handle;
26
-
onSubmit(currentHandle);
30
+
31
+
// Get the value directly from the input
32
+
const currentHandle = inputRef.current?.value || fields.handle.value;
33
+
setValue("handle", currentHandle);
34
+
35
+
// Validate
36
+
const isValid = validate("handle", validateHandle);
37
+
38
+
if (!isValid) {
39
+
return;
40
+
}
41
+
42
+
setIsSubmitting(true);
43
+
try {
44
+
await onSubmit(currentHandle);
45
+
} catch (error) {
46
+
// Error handling is done in parent component
47
+
setIsSubmitting(false);
48
+
}
27
49
};
28
50
29
51
return (
···
117
139
ref={inputRef}
118
140
id="atproto-handle"
119
141
type="text"
120
-
defaultValue={handle}
121
-
onInput={(e) =>
122
-
setHandle((e.target as HTMLInputElement).value)
123
-
}
142
+
{...getFieldProps("handle")}
124
143
placeholder="yourname.bsky.social"
125
-
className="w-full px-4 py-3 bg-purple-50/50 dark:bg-slate-900/50 border-2 border-cyan-500/50 dark:border-purple-500/30 rounded-xl text-purple-900 dark:text-cyan-100 placeholder-purple-750/80 dark:placeholder-cyan-250/80 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-amber-400 focus:border-transparent transition-all"
144
+
className={`w-full px-4 py-3 bg-purple-50/50 dark:bg-slate-900/50 border-2 rounded-xl text-purple-900 dark:text-cyan-100 placeholder-purple-750/80 dark:placeholder-cyan-250/80 focus:outline-none focus:ring-2 transition-all ${
145
+
fields.handle.touched && fields.handle.error
146
+
? "border-red-500 focus:ring-red-500"
147
+
: "border-cyan-500/50 dark:border-purple-500/30 focus:ring-orange-500 dark:focus:ring-amber-400 focus:border-transparent"
148
+
}`}
126
149
aria-required="true"
127
-
aria-describedby="handle-description"
150
+
aria-invalid={
151
+
fields.handle.touched && !!fields.handle.error
152
+
}
153
+
aria-describedby={
154
+
fields.handle.error
155
+
? "handle-error"
156
+
: "handle-description"
157
+
}
158
+
disabled={isSubmitting}
128
159
/>
129
160
</actor-typeahead>
161
+
{fields.handle.touched && fields.handle.error && (
162
+
<div
163
+
id="handle-error"
164
+
className="mt-2 flex items-center gap-2 text-sm text-red-600 dark:text-red-400"
165
+
role="alert"
166
+
>
167
+
<AlertCircle className="w-4 h-4 flex-shrink-0" />
168
+
<span>{fields.handle.error}</span>
169
+
</div>
170
+
)}
130
171
</div>
131
172
132
173
<button
133
174
type="submit"
134
-
className="w-full bg-firefly-banner dark:bg-firefly-banner-dark text-white py-4 rounded-xl font-bold text-lg transition-all shadow-lg hover:shadow-xl focus:ring-4 focus:ring-orange-500 dark:focus:ring-amber-400 focus:outline-none"
175
+
disabled={isSubmitting}
176
+
className="w-full bg-firefly-banner dark:bg-firefly-banner-dark text-white py-4 rounded-xl font-bold text-lg transition-all shadow-lg hover:shadow-xl focus:ring-4 focus:ring-orange-500 dark:focus:ring-amber-400 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
135
177
aria-label="Connect to the ATmosphere"
136
178
>
137
-
Join the Swarm
179
+
{isSubmitting ? (
180
+
<>
181
+
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
182
+
<span>Connecting...</span>
183
+
</>
184
+
) : (
185
+
"Join the Swarm"
186
+
)}
138
187
</button>
139
188
</form>
140
189
+10
-21
src/pages/Results.tsx
+10
-21
src/pages/Results.tsx
···
5
5
import FaviconIcon from "../components/FaviconIcon";
6
6
import type { AtprotoAppId } from "../types/settings";
7
7
import { getPlatform, getAtprotoApp } from "../lib/utils/platform";
8
+
import VirtualizedResultsList from "../components/VirtualizedResultsList";
8
9
9
10
interface atprotoSession {
10
11
did: string;
···
180
181
</div>
181
182
182
183
{/* Feed Results */}
183
-
<div className="max-w-3xl mx-auto px-4 py-4 space-y-4">
184
-
{sortedResults.map((result) => {
185
-
// Find the original index in unsorted array for state updates
186
-
const originalIndex = searchResults.findIndex(
187
-
(r) => r.sourceUser.username === result.sourceUser.username,
188
-
);
189
-
return (
190
-
<SearchResultCard
191
-
key={originalIndex}
192
-
result={result}
193
-
resultIndex={originalIndex}
194
-
isExpanded={expandedResults.has(originalIndex)}
195
-
onToggleExpand={() => onToggleExpand(originalIndex)}
196
-
onToggleMatchSelection={(did) =>
197
-
onToggleMatchSelection(originalIndex, did)
198
-
}
199
-
sourcePlatform={sourcePlatform}
200
-
destinationAppId={destinationAppId}
201
-
/>
202
-
);
203
-
})}
184
+
<div className="max-w-3xl mx-auto px-4 py-4">
185
+
<VirtualizedResultsList
186
+
results={sortedResults}
187
+
expandedResults={expandedResults}
188
+
onToggleExpand={onToggleExpand}
189
+
onToggleMatchSelection={onToggleMatchSelection}
190
+
sourcePlatform={sourcePlatform}
191
+
destinationAppId={destinationAppId}
192
+
/>
204
193
</div>
205
194
206
195
{/* Fixed Bottom Action Bar */}