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 "@icons-pack/react-simple-icons": "^13.8.0", 18 "@neondatabase/serverless": "^1.0.2", 19 "@netlify/functions": "^4.2.7", 20 "actor-typeahead": "^0.1.2", 21 "cookie": "^1.0.2", 22 "jose": "^6.1.0", ··· 59 "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.1.0.tgz", 60 "integrity": "sha512-6vRvRqJatDB+JUQsb+UswYmtBGQnSZcqC3a2y6H5DB/v5KcIh+6nFFtc17G0+3W9rxdk7k9M4KkgkdKf/YDNoQ==", 61 "license": "0BSD", 62 - "peer": true, 63 "dependencies": { 64 "@atcute/lexicons": "^1.1.1", 65 "@badrap/valita": "^0.4.5" ··· 412 "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", 413 "dev": true, 414 "license": "MIT", 415 - "peer": true, 416 "dependencies": { 417 "@babel/code-frame": "^7.27.1", 418 "@babel/generator": "^7.28.3", ··· 2576 "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", 2577 "dev": true, 2578 "license": "MIT", 2579 - "peer": true, 2580 "dependencies": { 2581 "@babel/core": "^7.21.3", 2582 "@svgr/babel-preset": "8.1.0", ··· 2633 "@svgr/core": "*" 2634 } 2635 }, 2636 "node_modules/@types/babel__core": { 2637 "version": "7.20.5", 2638 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", ··· 2726 "integrity": "sha512-ukd93VGzaNPMAUPy0gRDSC57UuQbnH9Kussp7HBjM06YFi9uZTFhOvMSO2OKqXm1rSgzOE+pVx1k1PYHGwlc8Q==", 2727 "dev": true, 2728 "license": "MIT", 2729 - "peer": true, 2730 "dependencies": { 2731 "csstype": "^3.0.2" 2732 } ··· 3080 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", 3081 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 3082 "license": "MIT", 3083 - "peer": true, 3084 "bin": { 3085 "acorn": "bin/acorn" 3086 }, ··· 3498 } 3499 ], 3500 "license": "MIT", 3501 - "peer": true, 3502 "dependencies": { 3503 "baseline-browser-mapping": "^2.8.3", 3504 "caniuse-lite": "^1.0.30001741", ··· 6324 } 6325 ], 6326 "license": "MIT", 6327 - "peer": true, 6328 "dependencies": { 6329 "nanoid": "^3.3.11", 6330 "picocolors": "^1.1.1", ··· 6624 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 6625 "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 6626 "license": "MIT", 6627 - "peer": true, 6628 "dependencies": { 6629 "loose-envify": "^1.1.0" 6630 }, ··· 7500 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", 7501 "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", 7502 "license": "Apache-2.0", 7503 - "peer": true, 7504 "bin": { 7505 "tsc": "bin/tsc", 7506 "tsserver": "bin/tsserver" ··· 7651 "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", 7652 "dev": true, 7653 "license": "MIT", 7654 - "peer": true, 7655 "dependencies": { 7656 "esbuild": "^0.21.3", 7657 "postcss": "^8.4.43",
··· 17 "@icons-pack/react-simple-icons": "^13.8.0", 18 "@neondatabase/serverless": "^1.0.2", 19 "@netlify/functions": "^4.2.7", 20 + "@tanstack/react-virtual": "^3.13.13", 21 "actor-typeahead": "^0.1.2", 22 "cookie": "^1.0.2", 23 "jose": "^6.1.0", ··· 60 "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.1.0.tgz", 61 "integrity": "sha512-6vRvRqJatDB+JUQsb+UswYmtBGQnSZcqC3a2y6H5DB/v5KcIh+6nFFtc17G0+3W9rxdk7k9M4KkgkdKf/YDNoQ==", 62 "license": "0BSD", 63 "dependencies": { 64 "@atcute/lexicons": "^1.1.1", 65 "@badrap/valita": "^0.4.5" ··· 412 "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", 413 "dev": true, 414 "license": "MIT", 415 "dependencies": { 416 "@babel/code-frame": "^7.27.1", 417 "@babel/generator": "^7.28.3", ··· 2575 "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", 2576 "dev": true, 2577 "license": "MIT", 2578 "dependencies": { 2579 "@babel/core": "^7.21.3", 2580 "@svgr/babel-preset": "8.1.0", ··· 2631 "@svgr/core": "*" 2632 } 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 + }, 2661 "node_modules/@types/babel__core": { 2662 "version": "7.20.5", 2663 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", ··· 2751 "integrity": "sha512-ukd93VGzaNPMAUPy0gRDSC57UuQbnH9Kussp7HBjM06YFi9uZTFhOvMSO2OKqXm1rSgzOE+pVx1k1PYHGwlc8Q==", 2752 "dev": true, 2753 "license": "MIT", 2754 "dependencies": { 2755 "csstype": "^3.0.2" 2756 } ··· 3104 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", 3105 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 3106 "license": "MIT", 3107 "bin": { 3108 "acorn": "bin/acorn" 3109 }, ··· 3521 } 3522 ], 3523 "license": "MIT", 3524 "dependencies": { 3525 "baseline-browser-mapping": "^2.8.3", 3526 "caniuse-lite": "^1.0.30001741", ··· 6346 } 6347 ], 6348 "license": "MIT", 6349 "dependencies": { 6350 "nanoid": "^3.3.11", 6351 "picocolors": "^1.1.1", ··· 6645 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 6646 "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 6647 "license": "MIT", 6648 "dependencies": { 6649 "loose-envify": "^1.1.0" 6650 }, ··· 7520 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", 7521 "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", 7522 "license": "Apache-2.0", 7523 "bin": { 7524 "tsc": "bin/tsc", 7525 "tsserver": "bin/tsserver" ··· 7670 "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", 7671 "dev": true, 7672 "license": "MIT", 7673 "dependencies": { 7674 "esbuild": "^0.21.3", 7675 "postcss": "^8.4.43",
+1
package.json
··· 22 "@icons-pack/react-simple-icons": "^13.8.0", 23 "@neondatabase/serverless": "^1.0.2", 24 "@netlify/functions": "^4.2.7", 25 "actor-typeahead": "^0.1.2", 26 "cookie": "^1.0.2", 27 "jose": "^6.1.0",
··· 22 "@icons-pack/react-simple-icons": "^13.8.0", 23 "@neondatabase/serverless": "^1.0.2", 24 "@netlify/functions": "^4.2.7", 25 + "@tanstack/react-virtual": "^3.13.13", 26 "actor-typeahead": "^0.1.2", 27 "cookie": "^1.0.2", 28 "jose": "^6.1.0",
+143 -136
src/App.tsx
··· 1 - import { useState, useEffect, useCallback } from "react"; 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 import { useAuth } from "./hooks/useAuth"; 8 import { useSearch } from "./hooks/useSearch"; 9 import { useFollow } from "./hooks/useFollows"; ··· 12 import { useNotifications } from "./hooks/useNotifications"; 13 import Firefly from "./components/Firefly"; 14 import NotificationContainer from "./components/common/NotificationContainer"; 15 import { DEFAULT_SETTINGS } from "./types/settings"; 16 import type { UserSettings, SearchResult } from "./types"; 17 import { apiClient } from "./lib/api/client"; 18 import { ATPROTO_APPS } from "./config/atprotoApps"; 19 20 export default function App() { 21 // Auth hook ··· 43 const [savedUploads, setSavedUploads] = useState<Set<string>>(new Set()); 44 45 // 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 - ); 62 63 // Search hook 64 const { ··· 229 }, [logout, setSearchResults, success, error]); 230 231 return ( 232 - <div className="min-h-screen relative overflow-hidden"> 233 - {/* Notification Container */} 234 - <NotificationContainer 235 - notifications={notifications} 236 - onRemove={removeNotification} 237 - /> 238 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 - ))} 245 </div> 246 - )} 247 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> 257 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> 265 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 - )} 283 284 - {/* Login Page */} 285 - {currentStep === "login" && ( 286 - <LoginPage 287 - onSubmit={handleLogin} 288 - session={session} 289 - onNavigate={setCurrentStep} 290 - reducedMotion={reducedMotion} 291 - /> 292 - )} 293 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 - )} 311 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> 355 ); 356 }
··· 1 + import React, { useState, useEffect, useCallback, Suspense, lazy } from "react"; 2 import { ArrowRight } from "lucide-react"; 3 import { useAuth } from "./hooks/useAuth"; 4 import { useSearch } from "./hooks/useSearch"; 5 import { useFollow } from "./hooks/useFollows"; ··· 8 import { useNotifications } from "./hooks/useNotifications"; 9 import Firefly from "./components/Firefly"; 10 import NotificationContainer from "./components/common/NotificationContainer"; 11 + import ErrorBoundary from "./components/common/ErrorBoundary"; 12 + import { SearchResultSkeleton } from "./components/common/LoadingSkeleton"; 13 import { DEFAULT_SETTINGS } from "./types/settings"; 14 import type { UserSettings, SearchResult } from "./types"; 15 import { apiClient } from "./lib/api/client"; 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 + ); 38 39 export default function App() { 40 // Auth hook ··· 62 const [savedUploads, setSavedUploads] = useState<Set<string>>(new Set()); 63 64 // Settings state 65 + const { settings: userSettings, updateSettings: handleSettingsUpdate } = 66 + useSettings(); 67 68 // Search hook 69 const { ··· 234 }, [logout, setSearchResults, success, error]); 235 236 return ( 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 + )} 257 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} 266 </div> 267 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> 275 276 + <main id="main-content"> 277 + <Suspense fallback={<PageLoader />}> 278 + {/* Checking Session */} 279 + {currentStep === "checking" && <PageLoader />} 280 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 + )} 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 + )} 312 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 + )} 330 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> 362 ); 363 }
+3 -11
src/components/HistoryTab.tsx
··· 3 import type { Upload as UploadType } from "../types"; 4 import FaviconIcon from "../components/FaviconIcon"; 5 import type { UserSettings } from "../types/settings"; 6 import { getPlatformColor } from "../lib/utils/platform"; 7 import { formatRelativeTime } from "../lib/utils/date"; 8 ··· 84 )} 85 86 {isLoading ? ( 87 - <div className="space-y-6"> 88 {[...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> 99 ))} 100 </div> 101 ) : uploads.length === 0 ? (
··· 3 import type { Upload as UploadType } from "../types"; 4 import FaviconIcon from "../components/FaviconIcon"; 5 import type { UserSettings } from "../types/settings"; 6 + import { UploadHistorySkeleton } from "./common/LoadingSkeleton"; 7 import { getPlatformColor } from "../lib/utils/platform"; 8 import { formatRelativeTime } from "../lib/utils/date"; 9 ··· 85 )} 86 87 {isLoading ? ( 88 + <div className="space-y-3"> 89 {[...Array(3)].map((_, i) => ( 90 + <UploadHistorySkeleton key={i} /> 91 ))} 92 </div> 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 import React from "react"; 2 import ReactDOM from "react-dom/client"; 3 import App from "./App"; 4 import "./index.css"; 5 6 ReactDOM.createRoot(document.getElementById("root")!).render( 7 <React.StrictMode> 8 - <App /> 9 </React.StrictMode>, 10 );
··· 1 import React from "react"; 2 import ReactDOM from "react-dom/client"; 3 import App from "./App"; 4 + import { SettingsProvider } from "./contexts/SettingsContext"; 5 import "./index.css"; 6 7 ReactDOM.createRoot(document.getElementById("root")!).render( 8 <React.StrictMode> 9 + <SettingsProvider> 10 + <App /> 11 + </SettingsProvider> 12 </React.StrictMode>, 13 );
+2 -30
src/pages/Loading.tsx
··· 1 import AppHeader from "../components/AppHeader"; 2 import { getPlatform } from "../lib/utils/platform"; 3 4 interface atprotoSession { ··· 148 {/* Skeleton Results - Matches layout of Results page */} 149 <div className="max-w-3xl mx-auto px-4 py-4 space-y-4"> 150 {[...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> 181 ))} 182 </div> 183 </div>
··· 1 import AppHeader from "../components/AppHeader"; 2 + import { SearchResultSkeleton } from "../components/common/LoadingSkeleton"; 3 import { getPlatform } from "../lib/utils/platform"; 4 5 interface atprotoSession { ··· 149 {/* Skeleton Results - Matches layout of Results page */} 150 <div className="max-w-3xl mx-auto px-4 py-4 space-y-4"> 151 {[...Array(8)].map((_, i) => ( 152 + <SearchResultSkeleton key={i} /> 153 ))} 154 </div> 155 </div>
+64 -15
src/pages/Login.tsx
··· 1 - import { useState, useRef } from "react"; 2 import "actor-typeahead"; 3 - import { Heart, Upload, Search, ArrowRight } from "lucide-react"; 4 import FireflyLogo from "../assets/at-firefly-logo.svg?react"; 5 6 interface LoginPageProps { 7 onSubmit: (handle: string) => void; ··· 16 onNavigate, 17 reducedMotion = false, 18 }: LoginPageProps) { 19 - const [handle, setHandle] = useState(""); 20 const inputRef = useRef<HTMLInputElement>(null); 21 22 - const handleSubmit = (e: React.FormEvent) => { 23 e.preventDefault(); 24 - // Get the value directly from the input instead of state 25 - const currentHandle = inputRef.current?.value || handle; 26 - onSubmit(currentHandle); 27 }; 28 29 return ( ··· 117 ref={inputRef} 118 id="atproto-handle" 119 type="text" 120 - defaultValue={handle} 121 - onInput={(e) => 122 - setHandle((e.target as HTMLInputElement).value) 123 - } 124 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" 126 aria-required="true" 127 - aria-describedby="handle-description" 128 /> 129 </actor-typeahead> 130 </div> 131 132 <button 133 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" 135 aria-label="Connect to the ATmosphere" 136 > 137 - Join the Swarm 138 </button> 139 </form> 140
··· 1 + import { useState, useRef, useEffect } from "react"; 2 import "actor-typeahead"; 3 + import { Heart, Upload, Search, ArrowRight, AlertCircle } from "lucide-react"; 4 import FireflyLogo from "../assets/at-firefly-logo.svg?react"; 5 + import { useFormValidation } from "../hooks/useFormValidation"; 6 + import { validateHandle } from "../lib/validation"; 7 8 interface LoginPageProps { 9 onSubmit: (handle: string) => void; ··· 18 onNavigate, 19 reducedMotion = false, 20 }: LoginPageProps) { 21 const inputRef = useRef<HTMLInputElement>(null); 22 + const [isSubmitting, setIsSubmitting] = useState(false); 23 24 + const { fields, setValue, validate, getFieldProps } = useFormValidation({ 25 + handle: "", 26 + }); 27 + 28 + const handleSubmit = async (e: React.FormEvent) => { 29 e.preventDefault(); 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 + } 49 }; 50 51 return ( ··· 139 ref={inputRef} 140 id="atproto-handle" 141 type="text" 142 + {...getFieldProps("handle")} 143 placeholder="yourname.bsky.social" 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 + }`} 149 aria-required="true" 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} 159 /> 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 + )} 171 </div> 172 173 <button 174 type="submit" 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" 177 aria-label="Connect to the ATmosphere" 178 > 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 + )} 187 </button> 188 </form> 189
+10 -21
src/pages/Results.tsx
··· 5 import FaviconIcon from "../components/FaviconIcon"; 6 import type { AtprotoAppId } from "../types/settings"; 7 import { getPlatform, getAtprotoApp } from "../lib/utils/platform"; 8 9 interface atprotoSession { 10 did: string; ··· 180 </div> 181 182 {/* 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 - })} 204 </div> 205 206 {/* Fixed Bottom Action Bar */}
··· 5 import FaviconIcon from "../components/FaviconIcon"; 6 import type { AtprotoAppId } from "../types/settings"; 7 import { getPlatform, getAtprotoApp } from "../lib/utils/platform"; 8 + import VirtualizedResultsList from "../components/VirtualizedResultsList"; 9 10 interface atprotoSession { 11 did: string; ··· 181 </div> 182 183 {/* Feed Results */} 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 + /> 193 </div> 194 195 {/* Fixed Bottom Action Bar */}