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

add error boundaries, loading skeleton thruout, code split, lazy pg cmpnts, setting context, form validation, and list virtualization. broke out-fill login handle, i'll fix that later

byarielm.fyi 151f5336 2ab139c7

verified
+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
··· 22 22 "@icons-pack/react-simple-icons": "^13.8.0", 23 23 "@neondatabase/serverless": "^1.0.2", 24 24 "@netlify/functions": "^4.2.7", 25 + "@tanstack/react-virtual": "^3.13.13", 25 26 "actor-typeahead": "^0.1.2", 26 27 "cookie": "^1.0.2", 27 28 "jose": "^6.1.0",
+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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 */}