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

cleanup types, components, api+state+performance improvements

byarielm.fyi 2ab139c7 083ea36c

verified
+135 -101
src/App.tsx
··· 1 - import { useState, useRef, useEffect } from "react"; 1 + import { useState, useEffect, useCallback } from "react"; 2 2 import { ArrowRight } from "lucide-react"; 3 3 import LoginPage from "./pages/Login"; 4 4 import HomePage from "./pages/Home"; ··· 9 9 import { useFollow } from "./hooks/useFollows"; 10 10 import { useFileUpload } from "./hooks/useFileUpload"; 11 11 import { useTheme } from "./hooks/useTheme"; 12 + import { useNotifications } from "./hooks/useNotifications"; 12 13 import Firefly from "./components/Firefly"; 14 + import NotificationContainer from "./components/common/NotificationContainer"; 13 15 import { DEFAULT_SETTINGS } from "./types/settings"; 14 - import type { UserSettings } from "./types/settings"; 16 + import type { UserSettings, SearchResult } from "./types"; 15 17 import { apiClient } from "./lib/api/client"; 16 18 import { ATPROTO_APPS } from "./config/atprotoApps"; 17 19 18 20 export default function App() { 19 - // Auth hook :) 21 + // Auth hook 20 22 const { 21 23 session, 22 24 currentStep, ··· 27 29 logout, 28 30 } = useAuth(); 29 31 32 + // Notifications hook (replaces alerts) 33 + const { notifications, removeNotification, success, error, info } = 34 + useNotifications(); 35 + 30 36 // Theme hook 31 37 const { isDark, reducedMotion, toggleTheme, toggleMotion } = useTheme(); 32 38 33 - // Add state to track current platform 39 + // Current platform state 34 40 const [currentPlatform, setCurrentPlatform] = useState<string>("tiktok"); 35 - const saveCalledRef = useRef<string | null>(null); // Track by uploadId 41 + 42 + // Track saved uploads to prevent duplicates 43 + const [savedUploads, setSavedUploads] = useState<Set<string>>(new Set()); 36 44 37 45 // Settings state 38 46 const [userSettings, setUserSettings] = useState<UserSettings>(() => { ··· 45 53 localStorage.setItem("atlast_settings", JSON.stringify(userSettings)); 46 54 }, [userSettings]); 47 55 48 - const handleSettingsUpdate = (newSettings: Partial<UserSettings>) => { 49 - setUserSettings((prev) => ({ ...prev, ...newSettings })); 50 - }; 56 + const handleSettingsUpdate = useCallback( 57 + (newSettings: Partial<UserSettings>) => { 58 + setUserSettings((prev) => ({ ...prev, ...newSettings })); 59 + }, 60 + [], 61 + ); 51 62 52 63 // Search hook 53 64 const { ··· 77 88 currentDestinationAppId, 78 89 ); 79 90 80 - // File upload hook 91 + // Save results handler (proper state management) 92 + const saveResults = useCallback( 93 + async (uploadId: string, platform: string, results: SearchResult[]) => { 94 + if (!userSettings.saveData) { 95 + console.log("Data storage disabled - skipping save to database"); 96 + return; 97 + } 98 + 99 + if (savedUploads.has(uploadId)) { 100 + console.log("Upload already saved:", uploadId); 101 + return; 102 + } 103 + 104 + try { 105 + setSavedUploads((prev) => new Set(prev).add(uploadId)); 106 + await apiClient.saveResults(uploadId, platform, results); 107 + console.log("Results saved successfully:", uploadId); 108 + } catch (err) { 109 + console.error("Background save failed:", err); 110 + setSavedUploads((prev) => { 111 + const next = new Set(prev); 112 + next.delete(uploadId); 113 + return next; 114 + }); 115 + } 116 + }, 117 + [userSettings.saveData, savedUploads], 118 + ); 119 + 120 + // File upload handler 81 121 const { handleFileUpload: processFileUpload } = useFileUpload( 82 122 (initialResults, platform) => { 83 123 setCurrentPlatform(platform); 84 - 85 124 setSearchResults(initialResults); 86 125 setCurrentStep("loading"); 87 126 ··· 89 128 const followLexicon = 90 129 ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 91 130 92 - searchAllUsers(initialResults, setStatusMessage, () => { 93 - setCurrentStep("results"); 131 + searchAllUsers( 132 + initialResults, 133 + setStatusMessage, 134 + () => { 135 + setCurrentStep("results"); 94 136 95 - // CONDITIONAL SAVE: Only save if user has enabled data storage 96 - if (userSettings.saveData) { 97 - // Prevent duplicate saves 98 - if (saveCalledRef.current !== uploadId) { 99 - saveCalledRef.current = uploadId; 100 - // Need to wait for React to finish updating searchResults state 101 - // Use a longer delay and access via setSearchResults callback to get final state 102 - setTimeout(() => { 103 - setSearchResults((currentResults) => { 104 - if (currentResults.length > 0) { 105 - apiClient 106 - .saveResults(uploadId, platform, currentResults) 107 - .then(() => { 108 - // Invalidate cache after successful save 109 - apiClient.cache.invalidate("uploads"); 110 - apiClient.cache.invalidatePattern("upload-details"); 111 - }) 112 - .catch((err) => { 113 - console.error("Background save failed:", err); 114 - }); 115 - } 116 - return currentResults; 117 - }); 118 - }, 1000); // Longer delay to ensure all state updates complete 119 - } 120 - } else { 121 - console.log("Data storage disabled - skipping save to database"); 122 - } 123 - }); 137 + // Save results after search completes 138 + setTimeout(() => { 139 + setSearchResults((currentResults) => { 140 + if (currentResults.length > 0) { 141 + saveResults(uploadId, platform, currentResults); 142 + } 143 + return currentResults; 144 + }); 145 + }, 1000); 146 + }, 147 + followLexicon, 148 + ); 124 149 }, 125 150 setStatusMessage, 126 - userSettings, // Pass userSettings to hook 151 + userSettings, 127 152 ); 128 153 129 154 // Load previous upload handler 130 - const handleLoadUpload = async (uploadId: string) => { 131 - try { 132 - setStatusMessage("Loading previous upload..."); 133 - setCurrentStep("loading"); 155 + const handleLoadUpload = useCallback( 156 + async (uploadId: string) => { 157 + try { 158 + setStatusMessage("Loading previous upload..."); 159 + setCurrentStep("loading"); 134 160 135 - const data = await apiClient.getUploadDetails(uploadId); 161 + const data = await apiClient.getUploadDetails(uploadId); 136 162 137 - if (data.results.length === 0) { 138 - setSearchResults([]); 139 - setCurrentPlatform("tiktok"); 140 - setCurrentStep("home"); 141 - setStatusMessage("No previous results found."); 142 - return; 143 - } 163 + if (data.results.length === 0) { 164 + setSearchResults([]); 165 + setCurrentPlatform("tiktok"); 166 + setCurrentStep("home"); 167 + info("No previous results found."); 168 + return; 169 + } 144 170 145 - const platform = "tiktok"; // Default, will be updated when we add platform to upload details 146 - setCurrentPlatform(platform); 147 - saveCalledRef.current = null; 171 + const platform = "tiktok"; 172 + setCurrentPlatform(platform); 148 173 149 - // Convert the loaded results to SearchResult format with selectedMatches 150 - const loadedResults = data.results.map((result) => ({ 151 - ...result, 152 - sourcePlatform: platform, 153 - isSearching: false, 154 - selectedMatches: new Set<string>( 155 - result.atprotoMatches 156 - .filter((match) => !match.followed) 157 - .slice(0, 1) 158 - .map((match) => match.did), 159 - ), 160 - })); 174 + const loadedResults: SearchResult[] = data.results.map((result) => ({ 175 + ...result, 176 + sourcePlatform: platform, 177 + isSearching: false, 178 + selectedMatches: new Set<string>( 179 + result.atprotoMatches 180 + .filter((match) => !match.followed) 181 + .slice(0, 1) 182 + .map((match) => match.did), 183 + ), 184 + })); 161 185 162 - setSearchResults(loadedResults); 163 - setCurrentStep("results"); 164 - setStatusMessage( 165 - `Loaded ${loadedResults.length} results from previous upload`, 166 - ); 167 - } catch (error) { 168 - console.error("Failed to load upload:", error); 169 - setStatusMessage("Failed to load previous upload"); 170 - setCurrentStep("home"); 171 - alert("Failed to load previous upload. Please try again."); 172 - } 173 - }; 186 + setSearchResults(loadedResults); 187 + setCurrentStep("results"); 188 + success(`Loaded ${loadedResults.length} results from previous upload`); 189 + } catch (err) { 190 + console.error("Failed to load upload:", err); 191 + error("Failed to load previous upload. Please try again."); 192 + setCurrentStep("home"); 193 + } 194 + }, 195 + [setStatusMessage, setCurrentStep, setSearchResults, info, error, success], 196 + ); 174 197 175 198 // Login handler 176 - const handleLogin = async (handle: string) => { 177 - if (!handle?.trim()) { 178 - setStatusMessage("Please enter your handle"); 179 - alert("Please enter your handle"); 180 - return; 181 - } 199 + const handleLogin = useCallback( 200 + async (handle: string) => { 201 + if (!handle?.trim()) { 202 + error("Please enter your handle"); 203 + return; 204 + } 182 205 183 - try { 184 - await login(handle); 185 - } catch (err) { 186 - console.error("OAuth error:", err); 187 - const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`; 188 - setStatusMessage(errorMsg); 189 - alert(errorMsg); 190 - } 191 - }; 206 + try { 207 + await login(handle); 208 + } catch (err) { 209 + console.error("OAuth error:", err); 210 + const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`; 211 + setStatusMessage(errorMsg); 212 + error(errorMsg); 213 + } 214 + }, 215 + [login, error, setStatusMessage], 216 + ); 192 217 193 218 // Logout handler 194 - const handleLogout = async () => { 219 + const handleLogout = useCallback(async () => { 195 220 try { 196 221 await logout(); 197 222 setSearchResults([]); 198 223 setCurrentPlatform("tiktok"); 199 - } catch (error) { 200 - alert("Failed to logout. Please try again."); 224 + setSavedUploads(new Set()); 225 + success("Logged out successfully"); 226 + } catch (err) { 227 + error("Failed to logout. Please try again."); 201 228 } 202 - }; 229 + }, [logout, setSearchResults, success, error]); 203 230 204 231 return ( 205 232 <div className="min-h-screen relative overflow-hidden"> 233 + {/* Notification Container */} 234 + <NotificationContainer 235 + notifications={notifications} 236 + onRemove={removeNotification} 237 + /> 238 + 206 239 {/* Firefly particles - only render if motion not reduced */} 207 240 {!reducedMotion && ( 208 241 <div className="fixed inset-0 pointer-events-none" aria-hidden="true"> ··· 235 268 {currentStep === "checking" && ( 236 269 <div className="p-6 max-w-md mx-auto mt-8"> 237 270 <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4"> 238 - <div className="w-16 h-16 bbg-firefly-banner dark:bg-firefly-banner-dark text-white rounded-2xl mx-auto flex items-center justify-center"> 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"> 239 272 <ArrowRight className="w-8 h-8 text-white animate-pulse" /> 240 273 </div> 241 274 <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100"> ··· 286 319 currentStep={currentStep} 287 320 sourcePlatform={currentPlatform} 288 321 isDark={isDark} 322 + reducedMotion={reducedMotion} 289 323 onToggleTheme={toggleTheme} 290 324 onToggleMotion={toggleMotion} 291 325 />
+155 -155
src/components/SearchResultCard.tsx
··· 1 - import { 2 - MessageCircle, 3 - Check, 4 - UserPlus, 5 - ChevronDown, 6 - UserCheck, 7 - } from "lucide-react"; 1 + import React, { useMemo } from "react"; 2 + import { MessageCircle, ChevronDown } from "lucide-react"; 8 3 import type { SearchResult } from "../types"; 9 4 import { getAtprotoAppWithFallback } from "../lib/utils/platform"; 10 5 import type { AtprotoAppId } from "../types/settings"; 11 6 import AvatarWithFallback from "./common/AvatarWithFallback"; 7 + import FollowButton from "./common/FollowButton"; 12 8 13 9 interface SearchResultCardProps { 14 10 result: SearchResult; ··· 20 16 destinationAppId?: AtprotoAppId; 21 17 } 22 18 23 - export default function SearchResultCard({ 24 - result, 25 - resultIndex, 26 - isExpanded, 27 - onToggleExpand, 28 - onToggleMatchSelection, 29 - sourcePlatform, 30 - destinationAppId = "bluesky", 31 - }: SearchResultCardProps) { 32 - const displayMatches = isExpanded 33 - ? result.atprotoMatches 34 - : result.atprotoMatches.slice(0, 1); 35 - const hasMoreMatches = result.atprotoMatches.length > 1; 36 - const currentApp = getAtprotoAppWithFallback(destinationAppId); 37 - const currentLexicon = currentApp?.followLexicon || "app.bsky.graph.follow"; 38 - 19 + // Memoize the match item to prevent unnecessary re-renders 20 + const MatchItem = React.memo<{ 21 + match: any; 22 + isSelected: boolean; 23 + isFollowed: boolean; 24 + currentAppName: string; 25 + onToggle: () => void; 26 + }>(({ match, isSelected, isFollowed, currentAppName, onToggle }) => { 39 27 return ( 40 - <div className="bg-white/50 dark:bg-slate-900/50 rounded-2xl shadow-sm overflow-hidden border-2 border-cyan-500/30 dark:border-purple-500/30"> 41 - {/* Source User */} 42 - <div className="px-4 py-3 bg-purple-100 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30"> 43 - <div className="flex justify-between gap-2 items-center"> 44 - <div className="flex-1 min-w-0"> 45 - <div className="flex flex-wrap gap-x-2 gap-y-1"> 46 - <span className="font-bold text-purple-950 dark:text-cyan-50 truncate text-base"> 47 - @{result.sourceUser.username} 48 - </span> 28 + <div className="flex items-start gap-3 p-3 cursor-pointer hover:scale-[1.01] transition-transform"> 29 + <AvatarWithFallback 30 + avatar={match.avatar} 31 + handle={match.handle || ""} 32 + size="sm" 33 + /> 34 + 35 + <div className="flex-1 min-w-0 space-y-1"> 36 + <div> 37 + {match.displayName && ( 38 + <div className="font-semibold text-purple-950 dark:text-cyan-50 leading-tight"> 39 + {match.displayName} 49 40 </div> 50 - </div> 51 - <div 52 - className={`text-sm text-purple-750 dark:text-cyan-250 whitespace-nowrap flex-shrink-0`} 41 + )} 42 + <a 43 + href={`https://bsky.app/profile/${match.handle}`} 44 + target="_blank" 45 + rel="noopener noreferrer" 46 + className="text-sm text-purple-750 dark:text-cyan-250 hover:underline leading-tight" 53 47 > 54 - {result.atprotoMatches.length}{" "} 55 - {result.atprotoMatches.length === 1 ? "match" : "matches"} 56 - </div> 48 + @{match.handle} 49 + </a> 57 50 </div> 58 - </div> 59 51 60 - {/* ATProto Matches */} 61 - {result.atprotoMatches.length === 0 ? ( 62 - <div className="text-center py-6"> 63 - <MessageCircle className="w-8 h-8 mx-auto mb-2 opacity-50 text-purple-750 dark:text-cyan-250" /> 64 - <p className="text-sm text-purple-950 dark:text-cyan-50"> 65 - Not found on the ATmosphere yet 66 - </p> 52 + <div className="flex items-center flex-wrap gap-2"> 53 + {typeof match.postCount === "number" && match.postCount > 0 && ( 54 + <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 55 + {match.postCount.toLocaleString()} posts 56 + </span> 57 + )} 58 + {typeof match.followerCount === "number" && 59 + match.followerCount > 0 && ( 60 + <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 61 + {match.followerCount.toLocaleString()} followers 62 + </span> 63 + )} 64 + <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 65 + {match.matchScore}% match 66 + </span> 67 67 </div> 68 - ) : ( 69 - <div className=""> 70 - {displayMatches.map((match) => { 71 - // Check follow status for current lexicon 72 - const isFollowedInCurrentApp = 73 - match.followStatus?.[currentLexicon] ?? match.followed ?? false; 74 - const isSelected = result.selectedMatches?.has(match.did); 75 68 76 - return ( 77 - <div 78 - key={match.did} 79 - className="flex items-start gap-3 p-3 cursor-pointer hover:scale-[1.01] transition-transform" 80 - > 81 - {/* Avatar */} 82 - <AvatarWithFallback 83 - avatar={match.avatar} 84 - handle={match.handle || ""} 85 - size="sm" 86 - /> 69 + {match.description && ( 70 + <div className="text-sm text-purple-900 dark:text-cyan-100 line-clamp-2 pt-1"> 71 + {match.description} 72 + </div> 73 + )} 74 + </div> 87 75 88 - {/* Match Info */} 89 - <div className="flex-1 min-w-0 space-y-1"> 90 - {/* Name and Handle */} 91 - <div> 92 - {match.displayName && ( 93 - <div className="font-semibold text-purple-950 dark:text-cyan-50 leading-tight"> 94 - {match.displayName} 95 - </div> 96 - )} 97 - <a 98 - href={`https://bsky.app/profile/${match.handle}`} 99 - target="_blank" 100 - rel="noopener noreferrer" 101 - className="text-sm text-purple-750 dark:text-cyan-250 hover:underline leading-tight" 102 - > 103 - @{match.handle} 104 - </a> 105 - </div> 76 + <FollowButton 77 + isFollowed={isFollowed} 78 + isSelected={isSelected} 79 + onToggle={onToggle} 80 + appName={currentAppName} 81 + /> 82 + </div> 83 + ); 84 + }); 106 85 107 - {/* User Stats and Match Percent */} 108 - <div className="flex items-center flex-wrap gap-2 sm:ml-0 -ml-10"> 109 - {typeof match.postCount === "number" && 110 - match.postCount > 0 && ( 111 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 112 - {match.postCount.toLocaleString()} posts 113 - </span> 114 - )} 115 - {typeof match.followerCount === "number" && 116 - match.followerCount > 0 && ( 117 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 118 - {match.followerCount.toLocaleString()} followers 119 - </span> 120 - )} 121 - <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium"> 122 - {match.matchScore}% match 123 - </span> 124 - </div> 86 + MatchItem.displayName = "MatchItem"; 125 87 126 - {/* Description */} 127 - {match.description && ( 128 - <div className="text-sm text-purple-900 dark:text-cyan-100 line-clamp-2 pt-1 sm:ml-0 -ml-10"> 129 - {match.description} 130 - </div> 131 - )} 132 - </div> 88 + const SearchResultCard = React.memo<SearchResultCardProps>( 89 + ({ 90 + result, 91 + resultIndex, 92 + isExpanded, 93 + onToggleExpand, 94 + onToggleMatchSelection, 95 + sourcePlatform, 96 + destinationAppId = "bluesky", 97 + }) => { 98 + const currentApp = useMemo( 99 + () => getAtprotoAppWithFallback(destinationAppId), 100 + [destinationAppId], 101 + ); 133 102 134 - {/* Select/Follow Button */} 135 - <button 136 - onClick={() => onToggleMatchSelection(match.did)} 137 - disabled={isFollowedInCurrentApp} 138 - className={`p-2 rounded-full font-medium transition-all flex-shrink-0 self-start ${ 139 - isFollowedInCurrentApp 140 - ? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md cursor-not-allowed opacity-50" 141 - : isSelected 142 - ? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md" 143 - : "bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400" 144 - }`} 145 - title={ 146 - isFollowedInCurrentApp 147 - ? `Already following on ${currentApp?.name || "this app"}` 148 - : isSelected 149 - ? "Selected to follow" 150 - : "Select to follow" 151 - } 152 - > 153 - {isFollowedInCurrentApp ? ( 154 - <Check className="w-4 h-4" /> 155 - ) : isSelected ? ( 156 - <UserCheck className="w-4 h-4" /> 157 - ) : ( 158 - <UserPlus className="w-4 h-4" /> 159 - )} 160 - </button> 103 + const currentLexicon = useMemo( 104 + () => currentApp?.followLexicon || "app.bsky.graph.follow", 105 + [currentApp], 106 + ); 107 + 108 + const displayMatches = useMemo( 109 + () => 110 + isExpanded ? result.atprotoMatches : result.atprotoMatches.slice(0, 1), 111 + [isExpanded, result.atprotoMatches], 112 + ); 113 + 114 + const hasMoreMatches = result.atprotoMatches.length > 1; 115 + 116 + return ( 117 + <div className="bg-white/50 dark:bg-slate-900/50 rounded-2xl shadow-sm overflow-hidden border-2 border-cyan-500/30 dark:border-purple-500/30"> 118 + {/* Source User */} 119 + <div className="px-4 py-3 bg-purple-100 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30"> 120 + <div className="flex justify-between gap-2 items-center"> 121 + <div className="flex-1 min-w-0"> 122 + <div className="flex flex-wrap gap-x-2 gap-y-1"> 123 + <span className="font-bold text-purple-950 dark:text-cyan-50 truncate text-base"> 124 + @{result.sourceUser.username} 125 + </span> 161 126 </div> 162 - ); 163 - })} 164 - {hasMoreMatches && ( 165 - <button 166 - onClick={onToggleExpand} 167 - className="w-full py-2 text-sm text-purple-600 hover:text-purple-950 dark:text-cyan-400 dark:hover:text-cyan-50 font-medium transition-colors flex items-center justify-center space-x-1 border-t-2 border-cyan-500/30 dark:border-purple-500/30 hover:border-orange-500 dark:hover:border-amber-400/50" 168 - > 169 - <span> 170 - {isExpanded 171 - ? "Show less" 172 - : `Show ${result.atprotoMatches.length - 1} more ${result.atprotoMatches.length - 1 === 1 ? "option" : "options"}`} 173 - </span> 174 - <ChevronDown 175 - className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} 176 - /> 177 - </button> 178 - )} 127 + </div> 128 + <div className="text-sm text-purple-750 dark:text-cyan-250 whitespace-nowrap flex-shrink-0"> 129 + {result.atprotoMatches.length}{" "} 130 + {result.atprotoMatches.length === 1 ? "match" : "matches"} 131 + </div> 132 + </div> 179 133 </div> 180 - )} 181 - </div> 182 - ); 183 - } 134 + 135 + {/* ATProto Matches */} 136 + {result.atprotoMatches.length === 0 ? ( 137 + <div className="text-center py-6"> 138 + <MessageCircle className="w-8 h-8 mx-auto mb-2 opacity-50 text-purple-750 dark:text-cyan-250" /> 139 + <p className="text-sm text-purple-950 dark:text-cyan-50"> 140 + Not found on the ATmosphere yet 141 + </p> 142 + </div> 143 + ) : ( 144 + <div> 145 + {displayMatches.map((match) => { 146 + const isFollowedInCurrentApp = 147 + match.followStatus?.[currentLexicon] ?? match.followed ?? false; 148 + const isSelected = result.selectedMatches?.has(match.did); 149 + 150 + return ( 151 + <MatchItem 152 + key={match.did} 153 + match={match} 154 + isSelected={isSelected || false} 155 + isFollowed={isFollowedInCurrentApp} 156 + currentAppName={currentApp?.name || "this app"} 157 + onToggle={() => onToggleMatchSelection(match.did)} 158 + /> 159 + ); 160 + })} 161 + {hasMoreMatches && ( 162 + <button 163 + onClick={onToggleExpand} 164 + className="w-full py-2 text-sm text-purple-600 hover:text-purple-950 dark:text-cyan-400 dark:hover:text-cyan-50 font-medium transition-colors flex items-center justify-center space-x-1 border-t-2 border-cyan-500/30 dark:border-purple-500/30 hover:border-orange-500 dark:hover:border-amber-400/50" 165 + > 166 + <span> 167 + {isExpanded 168 + ? "Show less" 169 + : `Show ${result.atprotoMatches.length - 1} more ${result.atprotoMatches.length - 1 === 1 ? "option" : "options"}`} 170 + </span> 171 + <ChevronDown 172 + className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} 173 + /> 174 + </button> 175 + )} 176 + </div> 177 + )} 178 + </div> 179 + ); 180 + }, 181 + ); 182 + SearchResultCard.displayName = "SearchResultCard"; 183 + export default SearchResultCard;
+34 -25
src/components/common/AvatarWithFallback.tsx
··· 1 + import React, { useState } from "react"; 2 + 1 3 interface AvatarWithFallbackProps { 2 4 avatar?: string; 3 5 handle: string; ··· 5 7 className?: string; 6 8 } 7 9 8 - export default function AvatarWithFallback({ 9 - avatar, 10 - handle, 11 - size = "md", 12 - className = "", 13 - }: AvatarWithFallbackProps) { 14 - const sizeClasses = { 15 - sm: "w-8 h-8 text-sm", 16 - md: "w-12 h-12 text-base", 17 - lg: "w-16 h-16 text-xl", 18 - }; 10 + const sizeClasses = { 11 + sm: { container: "w-8 h-8", text: "text-sm" }, 12 + md: { container: "w-12 h-12", text: "text-base" }, 13 + lg: { container: "w-16 h-16", text: "text-xl" }, 14 + }; 19 15 20 - const sizeClass = sizeClasses[size]; 16 + const AvatarWithFallback = React.memo<AvatarWithFallbackProps>( 17 + ({ avatar, handle, size = "md", className = "" }) => { 18 + const [imageError, setImageError] = useState(false); 19 + const { container, text } = sizeClasses[size]; 20 + 21 + const fallbackInitial = handle.charAt(0).toUpperCase(); 22 + 23 + if (!avatar || imageError) { 24 + return ( 25 + <div 26 + className={`${container} bg-gradient-to-br from-cyan-400 to-purple-500 rounded-full flex items-center justify-center shadow-sm ${className}`} 27 + aria-label={`${handle}'s avatar`} 28 + > 29 + <span className={`text-white font-bold ${text}`}> 30 + {fallbackInitial} 31 + </span> 32 + </div> 33 + ); 34 + } 21 35 22 - if (avatar) { 23 36 return ( 24 37 <img 25 38 src={avatar} 26 39 alt={`${handle}'s avatar`} 27 - className={`${sizeClass} rounded-full object-cover ${className}`} 40 + className={`${container} rounded-full object-cover ${className}`} 41 + onError={() => setImageError(true)} 42 + loading="lazy" 28 43 /> 29 44 ); 30 - } 45 + }, 46 + ); 31 47 32 - return ( 33 - <div 34 - className={`${sizeClass} bg-gradient-to-br from-cyan-400 to-purple-500 rounded-full flex items-center justify-center shadow-sm ${className}`} 35 - > 36 - <span className="text-white font-bold"> 37 - {handle.charAt(0).toUpperCase()} 38 - </span> 39 - </div> 40 - ); 41 - } 48 + AvatarWithFallback.displayName = "AvatarWithFallback"; 49 + 50 + export default AvatarWithFallback;
+65
src/components/common/Button.tsx
··· 1 + import React from "react"; 2 + import { LucideIcon } from "lucide-react"; 3 + 4 + interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { 5 + variant?: "primary" | "secondary" | "danger" | "ghost"; 6 + size?: "sm" | "md" | "lg"; 7 + isLoading?: boolean; 8 + icon?: LucideIcon; 9 + children: React.ReactNode; 10 + } 11 + 12 + const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 13 + ( 14 + { 15 + variant = "primary", 16 + size = "md", 17 + isLoading = false, 18 + icon: Icon, 19 + children, 20 + className = "", 21 + disabled, 22 + ...props 23 + }, 24 + ref, 25 + ) => { 26 + const baseStyles = 27 + "inline-flex items-center justify-center font-semibold transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"; 28 + 29 + const variants = { 30 + primary: 31 + "bg-firefly-banner dark:bg-firefly-banner-dark text-white hover:shadow-lg focus:ring-orange-500 dark:focus:ring-amber-400", 32 + secondary: 33 + "bg-slate-600 dark:bg-slate-700 hover:bg-slate-700 dark:hover:bg-slate-600 text-white focus:ring-slate-400", 34 + danger: "bg-red-600 hover:bg-red-700 text-white focus:ring-red-500", 35 + ghost: 36 + "bg-transparent hover:bg-purple-100 dark:hover:bg-slate-800 text-purple-900 dark:text-cyan-100 focus:ring-purple-500", 37 + }; 38 + 39 + const sizes = { 40 + sm: "px-3 py-1.5 text-sm rounded-lg", 41 + md: "px-4 py-2 text-base rounded-xl", 42 + lg: "px-6 py-3 text-lg rounded-xl", 43 + }; 44 + 45 + return ( 46 + <button 47 + ref={ref} 48 + disabled={disabled || isLoading} 49 + className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} 50 + {...props} 51 + > 52 + {isLoading ? ( 53 + <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" /> 54 + ) : Icon ? ( 55 + <Icon className="w-5 h-5 mr-2" /> 56 + ) : null} 57 + {children} 58 + </button> 59 + ); 60 + }, 61 + ); 62 + 63 + Button.displayName = "Button"; 64 + 65 + export default Button;
+51
src/components/common/FollowButton.tsx
··· 1 + import React from "react"; 2 + import { Check, UserPlus, UserCheck } from "lucide-react"; 3 + 4 + interface FollowButtonProps { 5 + isFollowed: boolean; 6 + isSelected: boolean; 7 + onToggle: () => void; 8 + disabled?: boolean; 9 + appName?: string; 10 + } 11 + 12 + const FollowButton = React.memo<FollowButtonProps>( 13 + ({ isFollowed, isSelected, onToggle, disabled = false, appName }) => { 14 + const getButtonStyles = () => { 15 + if (isFollowed) { 16 + return "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md cursor-not-allowed opacity-50"; 17 + } 18 + if (isSelected) { 19 + return "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md hover:scale-105"; 20 + } 21 + return "bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400 hover:scale-105"; 22 + }; 23 + 24 + const getTitle = () => { 25 + if (isFollowed) { 26 + return appName 27 + ? `Already following on ${appName}` 28 + : "Already following"; 29 + } 30 + return isSelected ? "Selected to follow" : "Select to follow"; 31 + }; 32 + 33 + const Icon = isFollowed ? Check : isSelected ? UserCheck : UserPlus; 34 + 35 + return ( 36 + <button 37 + onClick={onToggle} 38 + disabled={disabled || isFollowed} 39 + className={`p-2 rounded-full font-medium transition-all flex-shrink-0 self-start ${getButtonStyles()}`} 40 + title={getTitle()} 41 + aria-label={getTitle()} 42 + > 43 + <Icon className="w-4 h-4" /> 44 + </button> 45 + ); 46 + }, 47 + ); 48 + 49 + FollowButton.displayName = "FollowButton"; 50 + 51 + export default FollowButton;
+63
src/components/common/IconButton.tsx
··· 1 + import React from "react"; 2 + import { LucideIcon } from "lucide-react"; 3 + 4 + interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { 5 + icon: LucideIcon; 6 + label: string; 7 + variant?: "primary" | "secondary" | "ghost"; 8 + size?: "sm" | "md" | "lg"; 9 + } 10 + 11 + const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>( 12 + ( 13 + { 14 + icon: Icon, 15 + label, 16 + variant = "ghost", 17 + size = "md", 18 + className = "", 19 + ...props 20 + }, 21 + ref, 22 + ) => { 23 + const baseStyles = 24 + "inline-flex items-center justify-center rounded-full transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"; 25 + 26 + const variants = { 27 + primary: 28 + "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 hover:border-orange-500 dark:hover:border-amber-400", 29 + secondary: 30 + "bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400", 31 + ghost: 32 + "bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:bg-white dark:hover:bg-slate-700", 33 + }; 34 + 35 + const sizes = { 36 + sm: "p-1.5", 37 + md: "p-2", 38 + lg: "p-3", 39 + }; 40 + 41 + const iconSizes = { 42 + sm: "w-4 h-4", 43 + md: "w-5 h-5", 44 + lg: "w-6 h-6", 45 + }; 46 + 47 + return ( 48 + <button 49 + ref={ref} 50 + className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} 51 + aria-label={label} 52 + title={label} 53 + {...props} 54 + > 55 + <Icon className={iconSizes[size]} /> 56 + </button> 57 + ); 58 + }, 59 + ); 60 + 61 + IconButton.displayName = "IconButton"; 62 + 63 + export default IconButton;
+63
src/components/common/Notification.tsx
··· 1 + import React, { useEffect } from "react"; 2 + import { X, AlertCircle, CheckCircle, Info, AlertTriangle } from "lucide-react"; 3 + 4 + export type NotificationType = "success" | "error" | "info" | "warning"; 5 + 6 + interface NotificationProps { 7 + type: NotificationType; 8 + message: string; 9 + onClose: () => void; 10 + duration?: number; 11 + } 12 + 13 + const iconMap = { 14 + success: CheckCircle, 15 + error: AlertCircle, 16 + warning: AlertTriangle, 17 + info: Info, 18 + }; 19 + 20 + const styleMap = { 21 + success: 22 + "bg-green-50 dark:bg-green-900/20 border-green-500 text-green-900 dark:text-green-100", 23 + error: 24 + "bg-red-50 dark:bg-red-900/20 border-red-500 text-red-900 dark:text-red-100", 25 + warning: 26 + "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500 text-yellow-900 dark:text-yellow-100", 27 + info: "bg-blue-50 dark:bg-blue-900/20 border-blue-500 text-blue-900 dark:text-blue-100", 28 + }; 29 + 30 + const Notification: React.FC<NotificationProps> = ({ 31 + type, 32 + message, 33 + onClose, 34 + duration = 5000, 35 + }) => { 36 + const Icon = iconMap[type]; 37 + 38 + useEffect(() => { 39 + if (duration > 0) { 40 + const timer = setTimeout(onClose, duration); 41 + return () => clearTimeout(timer); 42 + } 43 + }, [duration, onClose]); 44 + 45 + return ( 46 + <div 47 + className={`flex items-start gap-3 p-4 rounded-xl border-2 shadow-lg ${styleMap[type]} animate-slide-in`} 48 + role="alert" 49 + > 50 + <Icon className="w-5 h-5 flex-shrink-0 mt-0.5" /> 51 + <p className="flex-1 text-sm font-medium">{message}</p> 52 + <button 53 + onClick={onClose} 54 + className="flex-shrink-0 hover:opacity-70 transition-opacity" 55 + aria-label="Close notification" 56 + > 57 + <X className="w-5 h-5" /> 58 + </button> 59 + </div> 60 + ); 61 + }; 62 + 63 + export default Notification;
+41
src/components/common/NotificationContainer.tsx
··· 1 + import React from "react"; 2 + import { createPortal } from "react-dom"; 3 + import Notification, { NotificationType } from "./Notification"; 4 + 5 + export interface NotificationItem { 6 + id: string; 7 + type: NotificationType; 8 + message: string; 9 + } 10 + 11 + interface NotificationContainerProps { 12 + notifications: NotificationItem[]; 13 + onRemove: (id: string) => void; 14 + } 15 + 16 + const NotificationContainer: React.FC<NotificationContainerProps> = ({ 17 + notifications, 18 + onRemove, 19 + }) => { 20 + if (notifications.length === 0) return null; 21 + 22 + return createPortal( 23 + <div 24 + className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-md" 25 + aria-live="polite" 26 + aria-atomic="false" 27 + > 28 + {notifications.map((notification) => ( 29 + <Notification 30 + key={notification.id} 31 + type={notification.type} 32 + message={notification.message} 33 + onClose={() => onRemove(notification.id)} 34 + /> 35 + ))} 36 + </div>, 37 + document.body, 38 + ); 39 + }; 40 + 41 + export default NotificationContainer;
+1 -3
src/hooks/useAuth.ts
··· 53 53 54 54 async function login(handle: string) { 55 55 if (!handle) { 56 - const errorMsg = "Please enter your handle"; 57 - setStatusMessage(errorMsg); 58 - throw new Error(errorMsg); 56 + throw new Error("Please enter your handle"); 59 57 } 60 58 61 59 setStatusMessage("Starting authentication...");
+61
src/hooks/useNotifications.ts
··· 1 + import { useState, useCallback } from "react"; 2 + import type { NotificationType } from "../components/common/Notification"; 3 + 4 + export interface NotificationItem { 5 + id: string; 6 + type: NotificationType; 7 + message: string; 8 + } 9 + 10 + export function useNotifications() { 11 + const [notifications, setNotifications] = useState<NotificationItem[]>([]); 12 + 13 + const addNotification = useCallback( 14 + (type: NotificationType, message: string) => { 15 + const id = `notification-${Date.now()}-${Math.random()}`; 16 + setNotifications((prev) => [...prev, { id, type, message }]); 17 + return id; 18 + }, 19 + [], 20 + ); 21 + 22 + const removeNotification = useCallback((id: string) => { 23 + setNotifications((prev) => prev.filter((n) => n.id !== id)); 24 + }, []); 25 + 26 + const clearAll = useCallback(() => { 27 + setNotifications([]); 28 + }, []); 29 + 30 + // Convenience methods 31 + const success = useCallback( 32 + (message: string) => addNotification("success", message), 33 + [addNotification], 34 + ); 35 + 36 + const error = useCallback( 37 + (message: string) => addNotification("error", message), 38 + [addNotification], 39 + ); 40 + 41 + const info = useCallback( 42 + (message: string) => addNotification("info", message), 43 + [addNotification], 44 + ); 45 + 46 + const warning = useCallback( 47 + (message: string) => addNotification("warning", message), 48 + [addNotification], 49 + ); 50 + 51 + return { 52 + notifications, 53 + addNotification, 54 + removeNotification, 55 + clearAll, 56 + success, 57 + error, 58 + info, 59 + warning, 60 + }; 61 + }
+16
src/index.css
··· 171 171 --color-hover: rgb(126 34 206 / 0.2); 172 172 --color-avatar-fallback: rgb(126 34 206 / 0.2); 173 173 } 174 + 175 + /* Notification animations */ 176 + @keyframes slide-in { 177 + from { 178 + transform: translateX(100%); 179 + opacity: 0; 180 + } 181 + to { 182 + transform: translateX(0); 183 + opacity: 1; 184 + } 185 + } 186 + 187 + .animate-slide-in { 188 + animation: slide-in 0.3s ease-out; 189 + }
+61 -18
src/lib/api/adapters/RealApiAdapter.ts
··· 6 6 SaveResultsResponse, 7 7 SearchResult, 8 8 } from "../../../types"; 9 - import { CacheService } from "../../../lib/utils/cache"; 9 + import { CacheService } from "../../utils/cache"; 10 10 import { CACHE_CONFIG } from "../../../config/constants"; 11 11 12 12 /** ··· 20 20 } 21 21 22 22 /** 23 + * Generate cache key for complex requests 24 + */ 25 + function generateCacheKey( 26 + prefix: string, 27 + ...parts: (string | number)[] 28 + ): string { 29 + return `${prefix}:${parts.join(":")}`; 30 + } 31 + 32 + /** 23 33 * Real API Client Adapter 24 - * Implements actual HTTP calls to backend 34 + * Implements actual HTTP calls to backend with optimized caching 25 35 */ 26 36 export class RealApiAdapter implements IApiClient { 27 37 private responseCache = new CacheService(CACHE_CONFIG.DEFAULT_TTL); ··· 121 131 results: SearchResult[]; 122 132 pagination?: any; 123 133 }> { 124 - const cacheKey = `upload-details-${uploadId}-p${page}-s${pageSize}`; 134 + const cacheKey = generateCacheKey( 135 + "upload-details", 136 + uploadId, 137 + page, 138 + pageSize, 139 + ); 125 140 const cached = this.responseCache.get<any>(cacheKey); 126 141 if (cached) { 127 142 return cached; ··· 146 161 async getAllUploadDetails( 147 162 uploadId: string, 148 163 ): Promise<{ results: SearchResult[] }> { 149 - const firstPage = await this.getUploadDetails(uploadId, 1, 100); 164 + // Check if we have all pages cached 165 + const firstPageKey = generateCacheKey("upload-details", uploadId, 1, 100); 166 + const firstPage = this.responseCache.get<any>(firstPageKey); 150 167 151 - if (!firstPage.pagination || firstPage.pagination.totalPages === 1) { 168 + if ( 169 + firstPage && 170 + (!firstPage.pagination || firstPage.pagination.totalPages === 1) 171 + ) { 152 172 return { results: firstPage.results }; 153 173 } 154 174 155 - const allResults = [...firstPage.results]; 156 - const promises = []; 175 + // Fetch first page to get total pages 176 + const firstPageData = await this.getUploadDetails(uploadId, 1, 100); 177 + 178 + if ( 179 + !firstPageData.pagination || 180 + firstPageData.pagination.totalPages === 1 181 + ) { 182 + return { results: firstPageData.results }; 183 + } 184 + 185 + // Fetch remaining pages in parallel 186 + const allResults = [...firstPageData.results]; 187 + const pagePromises = []; 157 188 158 - for (let page = 2; page <= firstPage.pagination.totalPages; page++) { 159 - promises.push(this.getUploadDetails(uploadId, page, 100)); 189 + for (let page = 2; page <= firstPageData.pagination.totalPages; page++) { 190 + pagePromises.push(this.getUploadDetails(uploadId, page, 100)); 160 191 } 161 192 162 - const remainingPages = await Promise.all(promises); 193 + const remainingPages = await Promise.all(pagePromises); 163 194 for (const pageData of remainingPages) { 164 195 allResults.push(...pageData.results); 165 196 } ··· 171 202 dids: string[], 172 203 followLexicon: string, 173 204 ): Promise<Record<string, boolean>> { 174 - const cacheKey = `follow-status-${followLexicon}-${dids.slice().sort().join(",")}`; 205 + // Sort DIDs for consistent cache key 206 + const sortedDids = [...dids].sort(); 207 + const cacheKey = generateCacheKey( 208 + "follow-status", 209 + followLexicon, 210 + sortedDids.join(","), 211 + ); 175 212 const cached = this.responseCache.get<Record<string, boolean>>(cacheKey); 176 213 if (cached) { 177 214 return cached; ··· 205 242 usernames: string[], 206 243 followLexicon?: string, 207 244 ): Promise<{ results: BatchSearchResult[] }> { 208 - const cacheKey = `search-${followLexicon || "default"}-${usernames.slice().sort().join(",")}`; 245 + // Sort usernames for consistent cache key 246 + const sortedUsernames = [...usernames].sort(); 247 + const cacheKey = generateCacheKey( 248 + "search", 249 + followLexicon || "default", 250 + sortedUsernames.join(","), 251 + ); 209 252 const cached = this.responseCache.get<any>(cacheKey); 210 253 if (cached) { 211 254 return cached; ··· 254 297 const response = await res.json(); 255 298 const data = unwrapResponse<any>(response); 256 299 257 - // Invalidate caches after following 258 - this.responseCache.invalidate("uploads"); 259 - this.responseCache.invalidatePattern("upload-details"); 260 - this.responseCache.invalidatePattern("follow-status"); 300 + // Invalidate relevant caches after following 301 + this.cache.invalidate("uploads"); 302 + this.cache.invalidatePattern("upload-details"); 303 + this.cache.invalidatePattern("follow-status"); 261 304 262 305 return data; 263 306 } ··· 291 334 const data = unwrapResponse<SaveResultsResponse>(response); 292 335 293 336 // Invalidate caches 294 - this.responseCache.invalidate("uploads"); 295 - this.responseCache.invalidatePattern("upload-details"); 337 + this.cache.invalidate("uploads"); 338 + this.cache.invalidatePattern("upload-details"); 296 339 297 340 return data; 298 341 } else {
+2
src/lib/utils/index.ts
··· 1 1 export * from "./platform"; 2 + export * from "./date"; 3 + export * from "./cache";
+51 -41
src/lib/utils/platform.ts
··· 2 2 import { ATPROTO_APPS, type AtprotoApp } from "../../config/atprotoApps"; 3 3 import type { AtprotoAppId } from "../../types/settings"; 4 4 5 + // Cache for platform lookups 6 + const platformCache = new Map<string, PlatformConfig>(); 7 + const appCache = new Map<AtprotoAppId, AtprotoApp>(); 8 + 5 9 /** 6 - * Get platform configuration by key 7 - * 8 - * @param platformKey - The platform identifier (e.g., "tiktok", "instagram") 9 - * @returns Platform configuration or default to TikTok 10 - **/ 10 + * Get platform configuration by key (memoized) 11 + */ 11 12 export function getPlatform(platformKey: string): PlatformConfig { 12 - return PLATFORMS[platformKey] || PLATFORMS.tiktok; 13 + if (!platformCache.has(platformKey)) { 14 + platformCache.set(platformKey, PLATFORMS[platformKey] || PLATFORMS.tiktok); 15 + } 16 + return platformCache.get(platformKey)!; 13 17 } 14 18 15 19 /** 16 20 * Get platform gradient color classes for UI 17 - * 18 - * @param platformKey - The platform identifier 19 - * @returns Tailwind gradient classes for the platform 20 - **/ 21 + */ 21 22 export function getPlatformColor(platformKey: string): string { 22 23 const colors: Record<string, string> = { 23 24 tiktok: "from-black via-gray-800 to-cyan-400", ··· 31 32 } 32 33 33 34 /** 34 - * Get ATProto app configuration by ID 35 - * 36 - * @param appId - The app identifier 37 - * @returns App configuration or undefined if not found 38 - **/ 35 + * Get ATProto app configuration by ID (memoized) 36 + */ 39 37 export function getAtprotoApp(appId: AtprotoAppId): AtprotoApp | undefined { 40 - return ATPROTO_APPS[appId]; 38 + if (!appCache.has(appId)) { 39 + const app = ATPROTO_APPS[appId]; 40 + if (app) { 41 + appCache.set(appId, app); 42 + } 43 + } 44 + return appCache.get(appId); 41 45 } 42 46 43 47 /** 44 - * Get ATProto app with fallback to default 45 - * 46 - * @param appId - The app identifier 47 - * @param defaultApp - Default app ID to use as fallback 48 - * @returns App configuration, falling back to default or Bluesky 49 - **/ 48 + * Get ATProto app with fallback to default (memoized) 49 + */ 50 50 export function getAtprotoAppWithFallback( 51 51 appId: AtprotoAppId, 52 52 defaultApp: AtprotoAppId = "bluesky", 53 53 ): AtprotoApp { 54 54 return ( 55 - ATPROTO_APPS[appId] || ATPROTO_APPS[defaultApp] || ATPROTO_APPS.bluesky 55 + getAtprotoApp(appId) || getAtprotoApp(defaultApp) || ATPROTO_APPS.bluesky 56 56 ); 57 57 } 58 58 59 59 /** 60 - * Get all enabled ATProto apps 61 - * 62 - * @returns Array of enabled app configurations 63 - **/ 60 + * Get all enabled ATProto apps (cached result) 61 + */ 62 + let enabledAppsCache: AtprotoApp[] | null = null; 64 63 export function getEnabledAtprotoApps(): AtprotoApp[] { 65 - return Object.values(ATPROTO_APPS).filter((app) => app.enabled); 64 + if (!enabledAppsCache) { 65 + enabledAppsCache = Object.values(ATPROTO_APPS).filter((app) => app.enabled); 66 + } 67 + return enabledAppsCache; 66 68 } 67 69 68 70 /** 69 - * Get all enabled platforms 70 - * 71 - * @returns Array of [key, config] tuples for enabled platforms 72 - **/ 71 + * Get all enabled platforms (cached result) 72 + */ 73 + let enabledPlatformsCache: Array<[string, PlatformConfig]> | null = null; 73 74 export function getEnabledPlatforms(): Array<[string, PlatformConfig]> { 74 - return Object.entries(PLATFORMS).filter(([_, config]) => config.enabled); 75 + if (!enabledPlatformsCache) { 76 + enabledPlatformsCache = Object.entries(PLATFORMS).filter( 77 + ([_, config]) => config.enabled, 78 + ); 79 + } 80 + return enabledPlatformsCache; 75 81 } 76 82 77 83 /** 78 84 * Check if a platform is enabled 79 - * 80 - * @param platformKey - The platform identifier 81 - * @returns True if platform is enabled 82 - **/ 85 + */ 83 86 export function isPlatformEnabled(platformKey: string): boolean { 84 87 return PLATFORMS[platformKey]?.enabled || false; 85 88 } 86 89 87 90 /** 88 91 * Check if an app is enabled 89 - * 90 - * @param appId - The app identifier 91 - * @returns True if app is enabled 92 - **/ 92 + */ 93 93 export function isAppEnabled(appId: AtprotoAppId): boolean { 94 94 return ATPROTO_APPS[appId]?.enabled || false; 95 95 } 96 + 97 + /** 98 + * Clear all caches (useful for hot reload in development) 99 + */ 100 + export function clearPlatformCaches(): void { 101 + platformCache.clear(); 102 + appCache.clear(); 103 + enabledAppsCache = null; 104 + enabledPlatformsCache = null; 105 + }
+26 -4
src/types/index.ts
··· 1 - export * from "./auth.types"; 2 - export * from "./search.types"; 3 - export * from "./common.types"; 1 + // Core type exports 2 + export type { 3 + AtprotoSession, 4 + UserSessionData, 5 + OAuthConfig, 6 + StateData, 7 + SessionData, 8 + } from "./auth.types"; 4 9 5 - // Re-export settings types for convenience 10 + export type { 11 + SourceUser, 12 + AtprotoMatch, 13 + SearchResult, 14 + SearchProgress, 15 + BatchSearchResult, 16 + BatchFollowResult, 17 + } from "./search.types"; 18 + 19 + export type { AppStep, Upload, SaveResultsResponse } from "./common.types"; 20 + 6 21 export type { 7 22 UserSettings, 8 23 PlatformDestinations, 9 24 AtprotoApp, 10 25 AtprotoAppId, 11 26 } from "./settings"; 27 + 28 + // Re-export for convenience 29 + export * from "./auth.types"; 30 + export * from "./search.types"; 31 + export * from "./common.types"; 32 + export * from "./settings"; 33 + export * from "./ui.types";
+30
src/types/ui.types.ts
··· 1 + import type { LucideIcon } from "lucide-react"; 2 + 3 + export interface BaseComponentProps { 4 + className?: string; 5 + } 6 + 7 + export interface AvatarProps extends BaseComponentProps { 8 + avatar?: string; 9 + handle: string; 10 + size?: "sm" | "md" | "lg"; 11 + } 12 + 13 + export interface ButtonVariant { 14 + variant?: "primary" | "secondary" | "danger" | "ghost"; 15 + size?: "sm" | "md" | "lg"; 16 + isLoading?: boolean; 17 + disabled?: boolean; 18 + } 19 + 20 + export interface IconButtonProps extends ButtonVariant, BaseComponentProps { 21 + icon: LucideIcon; 22 + label: string; 23 + onClick?: () => void; 24 + } 25 + 26 + export interface TabConfig { 27 + id: string; 28 + icon: LucideIcon; 29 + label: string; 30 + }