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

we linted

+75 -72
src/App.tsx
··· 30 const { isDark, reducedMotion, toggleTheme, toggleMotion } = useTheme(); 31 32 // Add state to track current platform 33 - const [currentPlatform, setCurrentPlatform] = useState<string>('tiktok'); 34 const saveCalledRef = useRef<string | null>(null); // Track by uploadId 35 36 // Settings state 37 const [userSettings, setUserSettings] = useState<UserSettings>(() => { 38 - const saved = localStorage.getItem('atlast_settings'); 39 return saved ? JSON.parse(saved) : DEFAULT_SETTINGS; 40 }); 41 42 // Save settings to localStorage whenever they change 43 useEffect(() => { 44 - localStorage.setItem('atlast_settings', JSON.stringify(userSettings)); 45 }, [userSettings]); 46 47 const handleSettingsUpdate = (newSettings: Partial<UserSettings>) => { 48 - setUserSettings(prev => ({ ...prev, ...newSettings })); 49 }; 50 51 // Search hook ··· 65 } = useSearch(session); 66 67 // Follow hook 68 - const { 69 - isFollowing, 70 - followSelectedUsers, 71 - } = useFollow(session, searchResults, setSearchResults); 72 73 // File upload hook 74 - const { 75 - handleFileUpload: processFileUpload, 76 - } = useFileUpload( 77 (initialResults, platform) => { 78 setCurrentPlatform(platform); 79 80 setSearchResults(initialResults); 81 - setCurrentStep('loading'); 82 83 const uploadId = crypto.randomUUID(); 84 85 - searchAllUsers( 86 - initialResults, 87 - setStatusMessage, 88 - () => { 89 - setCurrentStep('results'); 90 - // Prevent duplicate saves 91 - if (saveCalledRef.current !== uploadId) { 92 - saveCalledRef.current = uploadId; 93 - // Need to wait for React to finish updating searchResults state 94 - // Use a longer delay and access via setSearchResults callback to get final state 95 - setTimeout(() => { 96 - setSearchResults(currentResults => { 97 - if (currentResults.length > 0) { 98 - apiClient.saveResults(uploadId, platform, currentResults).catch(err => { 99 - console.error('Background save failed:', err); 100 }); 101 - } 102 - return currentResults; // Don't modify, just return as-is 103 - }); 104 - }, 1000); // Longer delay to ensure all state updates complete 105 - } 106 } 107 - ); 108 }, 109 - setStatusMessage 110 ); 111 112 // Load previous upload handler 113 const handleLoadUpload = async (uploadId: string) => { 114 try { 115 - setStatusMessage('Loading previous upload...'); 116 - setCurrentStep('loading'); 117 - 118 const data = await apiClient.getUploadDetails(uploadId); 119 - 120 - if (data.results.length === 0){ 121 setSearchResults([]); 122 - setCurrentPlatform('tiktok'); 123 - setCurrentStep('home'); 124 - setStatusMessage('No previous results found.'); 125 return; 126 } 127 128 - const platform = 'tiktok'; // Default, will be updated when we add platform to upload details 129 setCurrentPlatform(platform); 130 saveCalledRef.current = null; 131 132 // Convert the loaded results to SearchResult format with selectedMatches 133 - const loadedResults = data.results.map(result => ({ 134 ...result, 135 sourcePlatform: platform, 136 isSearching: false, 137 selectedMatches: new Set<string>( 138 result.atprotoMatches 139 - .filter(match => !match.followed) 140 .slice(0, 1) 141 - .map(match => match.did) 142 ), 143 })); 144 - 145 setSearchResults(loadedResults); 146 - setCurrentStep('results'); 147 - setStatusMessage(`Loaded ${loadedResults.length} results from previous upload`); 148 } catch (error) { 149 - console.error('Failed to load upload:', error); 150 - setStatusMessage('Failed to load previous upload'); 151 - setCurrentStep('home'); 152 - alert('Failed to load previous upload. Please try again.'); 153 } 154 }; 155 ··· 160 alert("Please enter your handle"); 161 return; 162 } 163 - 164 try { 165 await login(handle); 166 } catch (err) { 167 - console.error('OAuth error:', err); 168 - const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : 'Unknown error'}`; 169 setStatusMessage(errorMsg); 170 alert(errorMsg); 171 } ··· 176 try { 177 await logout(); 178 setSearchResults([]); 179 - setCurrentPlatform('tiktok'); 180 } catch (error) { 181 - alert('Failed to logout. Please try again.'); 182 } 183 }; 184 ··· 194 )} 195 196 {/* Status message for screen readers */} 197 - <div 198 - role="status" 199 - aria-live="polite" 200 aria-atomic="true" 201 className="sr-only" 202 > ··· 204 </div> 205 206 {/* Skip to main content link */} 207 - <a 208 - href="#main-content" 209 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" 210 > 211 Skip to main content ··· 213 214 <main id="main-content"> 215 {/* Checking Session */} 216 - {currentStep === 'checking' && ( 217 <div className="p-6 max-w-md mx-auto mt-8"> 218 <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4"> 219 <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"> 220 <ArrowRight className="w-8 h-8 text-white animate-pulse" /> 221 </div> 222 - <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">Loading...</h2> 223 - <p className="text-gray-600 dark:text-gray-300">Checking your session</p> 224 </div> 225 </div> 226 )} 227 228 {/* Login Page */} 229 - {currentStep === 'login' && ( 230 - <LoginPage 231 onSubmit={handleLogin} 232 session={session} 233 onNavigate={setCurrentStep} ··· 236 )} 237 238 {/* Home/Dashboard Page */} 239 - {currentStep === 'home' && ( 240 <HomePage 241 session={session} 242 onLogout={handleLogout} ··· 254 )} 255 256 {/* Loading Page */} 257 - {currentStep === 'loading' && ( 258 <LoadingPage 259 session={session} 260 onLogout={handleLogout} ··· 269 )} 270 271 {/* Results Page */} 272 - {currentStep === 'results' && ( 273 <ResultsPage 274 session={session} 275 onLogout={handleLogout} ··· 295 </main> 296 </div> 297 ); 298 - }
··· 30 const { isDark, reducedMotion, toggleTheme, toggleMotion } = useTheme(); 31 32 // Add state to track current platform 33 + const [currentPlatform, setCurrentPlatform] = useState<string>("tiktok"); 34 const saveCalledRef = useRef<string | null>(null); // Track by uploadId 35 36 // Settings state 37 const [userSettings, setUserSettings] = useState<UserSettings>(() => { 38 + const saved = localStorage.getItem("atlast_settings"); 39 return saved ? JSON.parse(saved) : DEFAULT_SETTINGS; 40 }); 41 42 // Save settings to localStorage whenever they change 43 useEffect(() => { 44 + localStorage.setItem("atlast_settings", JSON.stringify(userSettings)); 45 }, [userSettings]); 46 47 const handleSettingsUpdate = (newSettings: Partial<UserSettings>) => { 48 + setUserSettings((prev) => ({ ...prev, ...newSettings })); 49 }; 50 51 // Search hook ··· 65 } = useSearch(session); 66 67 // Follow hook 68 + const { isFollowing, followSelectedUsers } = useFollow( 69 + session, 70 + searchResults, 71 + setSearchResults, 72 + ); 73 74 // File upload hook 75 + const { handleFileUpload: processFileUpload } = useFileUpload( 76 (initialResults, platform) => { 77 setCurrentPlatform(platform); 78 79 setSearchResults(initialResults); 80 + setCurrentStep("loading"); 81 82 const uploadId = crypto.randomUUID(); 83 84 + searchAllUsers(initialResults, setStatusMessage, () => { 85 + setCurrentStep("results"); 86 + // Prevent duplicate saves 87 + if (saveCalledRef.current !== uploadId) { 88 + saveCalledRef.current = uploadId; 89 + // Need to wait for React to finish updating searchResults state 90 + // Use a longer delay and access via setSearchResults callback to get final state 91 + setTimeout(() => { 92 + setSearchResults((currentResults) => { 93 + if (currentResults.length > 0) { 94 + apiClient 95 + .saveResults(uploadId, platform, currentResults) 96 + .catch((err) => { 97 + console.error("Background save failed:", err); 98 }); 99 + } 100 + return currentResults; // Don't modify, just return as-is 101 + }); 102 + }, 1000); // Longer delay to ensure all state updates complete 103 } 104 + }); 105 }, 106 + setStatusMessage, 107 ); 108 109 // Load previous upload handler 110 const handleLoadUpload = async (uploadId: string) => { 111 try { 112 + setStatusMessage("Loading previous upload..."); 113 + setCurrentStep("loading"); 114 + 115 const data = await apiClient.getUploadDetails(uploadId); 116 + 117 + if (data.results.length === 0) { 118 setSearchResults([]); 119 + setCurrentPlatform("tiktok"); 120 + setCurrentStep("home"); 121 + setStatusMessage("No previous results found."); 122 return; 123 } 124 125 + const platform = "tiktok"; // Default, will be updated when we add platform to upload details 126 setCurrentPlatform(platform); 127 saveCalledRef.current = null; 128 129 // Convert the loaded results to SearchResult format with selectedMatches 130 + const loadedResults = data.results.map((result) => ({ 131 ...result, 132 sourcePlatform: platform, 133 isSearching: false, 134 selectedMatches: new Set<string>( 135 result.atprotoMatches 136 + .filter((match) => !match.followed) 137 .slice(0, 1) 138 + .map((match) => match.did), 139 ), 140 })); 141 + 142 setSearchResults(loadedResults); 143 + setCurrentStep("results"); 144 + setStatusMessage( 145 + `Loaded ${loadedResults.length} results from previous upload`, 146 + ); 147 } catch (error) { 148 + console.error("Failed to load upload:", error); 149 + setStatusMessage("Failed to load previous upload"); 150 + setCurrentStep("home"); 151 + alert("Failed to load previous upload. Please try again."); 152 } 153 }; 154 ··· 159 alert("Please enter your handle"); 160 return; 161 } 162 + 163 try { 164 await login(handle); 165 } catch (err) { 166 + console.error("OAuth error:", err); 167 + const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`; 168 setStatusMessage(errorMsg); 169 alert(errorMsg); 170 } ··· 175 try { 176 await logout(); 177 setSearchResults([]); 178 + setCurrentPlatform("tiktok"); 179 } catch (error) { 180 + alert("Failed to logout. Please try again."); 181 } 182 }; 183 ··· 193 )} 194 195 {/* Status message for screen readers */} 196 + <div 197 + role="status" 198 + aria-live="polite" 199 aria-atomic="true" 200 className="sr-only" 201 > ··· 203 </div> 204 205 {/* Skip to main content link */} 206 + <a 207 + href="#main-content" 208 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" 209 > 210 Skip to main content ··· 212 213 <main id="main-content"> 214 {/* Checking Session */} 215 + {currentStep === "checking" && ( 216 <div className="p-6 max-w-md mx-auto mt-8"> 217 <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4"> 218 <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"> 219 <ArrowRight className="w-8 h-8 text-white animate-pulse" /> 220 </div> 221 + <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100"> 222 + Loading... 223 + </h2> 224 + <p className="text-gray-600 dark:text-gray-300"> 225 + Checking your session 226 + </p> 227 </div> 228 </div> 229 )} 230 231 {/* Login Page */} 232 + {currentStep === "login" && ( 233 + <LoginPage 234 onSubmit={handleLogin} 235 session={session} 236 onNavigate={setCurrentStep} ··· 239 )} 240 241 {/* Home/Dashboard Page */} 242 + {currentStep === "home" && ( 243 <HomePage 244 session={session} 245 onLogout={handleLogout} ··· 257 )} 258 259 {/* Loading Page */} 260 + {currentStep === "loading" && ( 261 <LoadingPage 262 session={session} 263 onLogout={handleLogout} ··· 272 )} 273 274 {/* Results Page */} 275 + {currentStep === "results" && ( 276 <ResultsPage 277 session={session} 278 onLogout={handleLogout} ··· 298 </main> 299 </div> 300 ); 301 + }
+2 -2
src/components/Firefly.tsx
··· 11 }; 12 13 return ( 14 - <div 15 className="absolute w-1 h-1 bg-firefly-amber dark:bg-firefly-glow rounded-full opacity-40 pointer-events-none" 16 style={style} 17 aria-hidden="true" ··· 19 <div className="absolute inset-0 bg-firefly-glow dark:bg-firefly-amber rounded-full animate-pulse blur-sm" /> 20 </div> 21 ); 22 - }
··· 11 }; 12 13 return ( 14 + <div 15 className="absolute w-1 h-1 bg-firefly-amber dark:bg-firefly-glow rounded-full opacity-40 pointer-events-none" 16 style={style} 17 aria-hidden="true" ··· 19 <div className="absolute inset-0 bg-firefly-glow dark:bg-firefly-amber rounded-full animate-pulse blur-sm" /> 20 </div> 21 ); 22 + }
+39 -32
src/constants/platforms.ts
··· 1 - import { Twitter, Instagram, Video, Hash, Gamepad2, LucideIcon } from "lucide-react"; 2 3 export interface PlatformConfig { 4 name: string; ··· 12 13 export const PLATFORMS: Record<string, PlatformConfig> = { 14 twitter: { 15 - name: 'Twitter/X', 16 icon: Twitter, 17 - color: 'from-blue-400 to-blue-600', 18 - accentBg: 'bg-blue-500', 19 - fileHint: 'following.txt, data.json, or data.zip', 20 enabled: false, 21 - defaultApp: 'bluesky', 22 }, 23 instagram: { 24 - name: 'Instagram', 25 icon: Instagram, 26 - color: 'from-pink-500 via-purple-500 to-orange-500', 27 - accentBg: 'bg-pink-500', 28 - fileHint: 'following.html or data ZIP', 29 enabled: true, 30 - defaultApp: 'bluesky', 31 }, 32 tiktok: { 33 - name: 'TikTok', 34 icon: Video, 35 - color: 'from-black via-gray-800 to-cyan-400', 36 - accentBg: 'bg-black', 37 - fileHint: 'Following.txt or data ZIP', 38 enabled: true, 39 - defaultApp: 'spark', 40 }, 41 tumblr: { 42 - name: 'Tumblr', 43 icon: Hash, 44 - color: 'from-indigo-600 to-blue-800', 45 - accentBg: 'bg-indigo-600', 46 - fileHint: 'following.csv or data export', 47 enabled: false, 48 - defaultApp: 'bluesky', 49 }, 50 twitch: { 51 - name: 'Twitch', 52 icon: Gamepad2, 53 - color: 'from-purple-600 to-purple-800', 54 - accentBg: 'bg-purple-600', 55 - fileHint: 'following.json or data export', 56 enabled: false, 57 - defaultApp: 'bluesky' 58 }, 59 youtube: { 60 - name: 'YouTube', 61 icon: Video, 62 - color: 'from-red-600 to-red-700', 63 - accentBg: 'bg-red-600', 64 - fileHint: 'subscriptions.csv or Takeout ZIP', 65 enabled: false, 66 - defaultApp: 'bluesky' 67 }, 68 }; 69 ··· 74 75 export const FOLLOW_CONFIG = { 76 BATCH_SIZE: 50, 77 - };
··· 1 + import { 2 + Twitter, 3 + Instagram, 4 + Video, 5 + Hash, 6 + Gamepad2, 7 + LucideIcon, 8 + } from "lucide-react"; 9 10 export interface PlatformConfig { 11 name: string; ··· 19 20 export const PLATFORMS: Record<string, PlatformConfig> = { 21 twitter: { 22 + name: "Twitter/X", 23 icon: Twitter, 24 + color: "from-blue-400 to-blue-600", 25 + accentBg: "bg-blue-500", 26 + fileHint: "following.txt, data.json, or data.zip", 27 enabled: false, 28 + defaultApp: "bluesky", 29 }, 30 instagram: { 31 + name: "Instagram", 32 icon: Instagram, 33 + color: "from-pink-500 via-purple-500 to-orange-500", 34 + accentBg: "bg-pink-500", 35 + fileHint: "following.html or data ZIP", 36 enabled: true, 37 + defaultApp: "bluesky", 38 }, 39 tiktok: { 40 + name: "TikTok", 41 icon: Video, 42 + color: "from-black via-gray-800 to-cyan-400", 43 + accentBg: "bg-black", 44 + fileHint: "Following.txt or data ZIP", 45 enabled: true, 46 + defaultApp: "spark", 47 }, 48 tumblr: { 49 + name: "Tumblr", 50 icon: Hash, 51 + color: "from-indigo-600 to-blue-800", 52 + accentBg: "bg-indigo-600", 53 + fileHint: "following.csv or data export", 54 enabled: false, 55 + defaultApp: "bluesky", 56 }, 57 twitch: { 58 + name: "Twitch", 59 icon: Gamepad2, 60 + color: "from-purple-600 to-purple-800", 61 + accentBg: "bg-purple-600", 62 + fileHint: "following.json or data export", 63 enabled: false, 64 + defaultApp: "bluesky", 65 }, 66 youtube: { 67 + name: "YouTube", 68 icon: Video, 69 + color: "from-red-600 to-red-700", 70 + accentBg: "bg-red-600", 71 + fileHint: "subscriptions.csv or Takeout ZIP", 72 enabled: false, 73 + defaultApp: "bluesky", 74 }, 75 }; 76 ··· 81 82 export const FOLLOW_CONFIG = { 83 BATCH_SIZE: 50, 84 + };
+19 -15
src/hooks/useFileUpload.ts
··· 1 - import { parseDataFile } from '../lib/fileExtractor'; 2 - import type { SearchResult } from '../types'; 3 4 export function useFileUpload( 5 onSearchStart: (results: SearchResult[], platform: string) => void, 6 - onStatusUpdate: (message: string) => void 7 ) { 8 - async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>, platform: string = 'tiktok') { 9 const file = e.target.files?.[0]; 10 if (!file) return; 11 ··· 14 15 try { 16 usernames = await parseDataFile(file, platform); 17 - 18 console.log(`Loaded ${usernames.length} users from ${platform} data`); 19 onStatusUpdate(`Loaded ${usernames.length} users from ${platform} data`); 20 } catch (error) { 21 console.error("Error processing file:", error); 22 - 23 - const errorMsg = error instanceof Error 24 - ? error.message 25 - : "There was a problem processing the file. Please check that it's a valid data export."; 26 - 27 onStatusUpdate(errorMsg); 28 alert(errorMsg); 29 return; 30 } 31 - 32 if (usernames.length === 0) { 33 const errorMsg = "No users found in the file."; 34 onStatusUpdate(errorMsg); ··· 37 } 38 39 // Initialize search results - convert usernames to SearchResult format 40 - const initialResults: SearchResult[] = usernames.map(username => ({ 41 sourceUser: { 42 username: username, 43 - date: '' 44 }, 45 atprotoMatches: [], 46 isSearching: false, 47 selectedMatches: new Set<string>(), 48 - sourcePlatform: platform 49 })); 50 51 onStatusUpdate(`Starting search for ${usernames.length} users...`); ··· 55 return { 56 handleFileUpload, 57 }; 58 - }
··· 1 + import { parseDataFile } from "../lib/fileExtractor"; 2 + import type { SearchResult } from "../types"; 3 4 export function useFileUpload( 5 onSearchStart: (results: SearchResult[], platform: string) => void, 6 + onStatusUpdate: (message: string) => void, 7 ) { 8 + async function handleFileUpload( 9 + e: React.ChangeEvent<HTMLInputElement>, 10 + platform: string = "tiktok", 11 + ) { 12 const file = e.target.files?.[0]; 13 if (!file) return; 14 ··· 17 18 try { 19 usernames = await parseDataFile(file, platform); 20 + 21 console.log(`Loaded ${usernames.length} users from ${platform} data`); 22 onStatusUpdate(`Loaded ${usernames.length} users from ${platform} data`); 23 } catch (error) { 24 console.error("Error processing file:", error); 25 + 26 + const errorMsg = 27 + error instanceof Error 28 + ? error.message 29 + : "There was a problem processing the file. Please check that it's a valid data export."; 30 + 31 onStatusUpdate(errorMsg); 32 alert(errorMsg); 33 return; 34 } 35 + 36 if (usernames.length === 0) { 37 const errorMsg = "No users found in the file."; 38 onStatusUpdate(errorMsg); ··· 41 } 42 43 // Initialize search results - convert usernames to SearchResult format 44 + const initialResults: SearchResult[] = usernames.map((username) => ({ 45 sourceUser: { 46 username: username, 47 + date: "", 48 }, 49 atprotoMatches: [], 50 isSearching: false, 51 selectedMatches: new Set<string>(), 52 + sourcePlatform: platform, 53 })); 54 55 onStatusUpdate(`Starting search for ${usernames.length} users...`); ··· 59 return { 60 handleFileUpload, 61 }; 62 + }
+40 -31
src/hooks/useFollows.ts
··· 1 - import { useState } from 'react'; 2 - import { apiClient } from '../lib/apiClient'; 3 - import { FOLLOW_CONFIG } from '../constants/platforms'; 4 - import type { SearchResult, AtprotoSession } from '../types'; 5 6 export function useFollow( 7 session: AtprotoSession | null, 8 searchResults: SearchResult[], 9 - setSearchResults: (results: SearchResult[] | ((prev: SearchResult[]) => SearchResult[])) => void 10 ) { 11 const [isFollowing, setIsFollowing] = useState(false); 12 13 async function followSelectedUsers( 14 - onUpdate: (message: string) => void 15 ): Promise<void> { 16 if (!session || isFollowing) return; 17 18 - const selectedUsers = searchResults.flatMap((result, resultIndex) => 19 result.atprotoMatches 20 - .filter(match => result.selectedMatches?.has(match.did)) 21 - .map(match => ({ ...match, resultIndex })) 22 ); 23 24 if (selectedUsers.length === 0) { ··· 35 36 try { 37 const { BATCH_SIZE } = FOLLOW_CONFIG; 38 - 39 for (let i = 0; i < selectedUsers.length; i += BATCH_SIZE) { 40 const batch = selectedUsers.slice(i, i + BATCH_SIZE); 41 - const dids = batch.map(user => user.did); 42 - 43 try { 44 const data = await apiClient.batchFollowUsers(dids); 45 totalFollowed += data.succeeded; 46 totalFailed += data.failed; 47 - 48 // Mark successful follows in UI 49 data.results.forEach((result) => { 50 if (result.success) { 51 - const user = batch.find(u => u.did === result.did); 52 if (user) { 53 - setSearchResults(prev => prev.map((searchResult, index) => 54 - index === user.resultIndex 55 - ? { 56 - ...searchResult, 57 - atprotoMatches: searchResult.atprotoMatches.map(match => 58 - match.did === result.did ? { ...match, followed: true } : match 59 - ) 60 - } 61 - : searchResult 62 - )); 63 } 64 } 65 }); 66 - 67 - onUpdate(`Followed ${totalFollowed} of ${selectedUsers.length} users`); 68 } catch (error) { 69 totalFailed += batch.length; 70 - console.error('Batch follow error:', error); 71 } 72 - 73 // Rate limit handling is in the backend 74 } 75 - 76 - const finalMsg = `Successfully followed ${totalFollowed} users${totalFailed > 0 ? `. ${totalFailed} failed.` : ''}`; 77 onUpdate(finalMsg); 78 } catch (error) { 79 console.error("Batch follow error:", error); ··· 87 isFollowing, 88 followSelectedUsers, 89 }; 90 - }
··· 1 + import { useState } from "react"; 2 + import { apiClient } from "../lib/apiClient"; 3 + import { FOLLOW_CONFIG } from "../constants/platforms"; 4 + import type { SearchResult, AtprotoSession } from "../types"; 5 6 export function useFollow( 7 session: AtprotoSession | null, 8 searchResults: SearchResult[], 9 + setSearchResults: ( 10 + results: SearchResult[] | ((prev: SearchResult[]) => SearchResult[]), 11 + ) => void, 12 ) { 13 const [isFollowing, setIsFollowing] = useState(false); 14 15 async function followSelectedUsers( 16 + onUpdate: (message: string) => void, 17 ): Promise<void> { 18 if (!session || isFollowing) return; 19 20 + const selectedUsers = searchResults.flatMap((result, resultIndex) => 21 result.atprotoMatches 22 + .filter((match) => result.selectedMatches?.has(match.did)) 23 + .map((match) => ({ ...match, resultIndex })), 24 ); 25 26 if (selectedUsers.length === 0) { ··· 37 38 try { 39 const { BATCH_SIZE } = FOLLOW_CONFIG; 40 + 41 for (let i = 0; i < selectedUsers.length; i += BATCH_SIZE) { 42 const batch = selectedUsers.slice(i, i + BATCH_SIZE); 43 + const dids = batch.map((user) => user.did); 44 + 45 try { 46 const data = await apiClient.batchFollowUsers(dids); 47 totalFollowed += data.succeeded; 48 totalFailed += data.failed; 49 + 50 // Mark successful follows in UI 51 data.results.forEach((result) => { 52 if (result.success) { 53 + const user = batch.find((u) => u.did === result.did); 54 if (user) { 55 + setSearchResults((prev) => 56 + prev.map((searchResult, index) => 57 + index === user.resultIndex 58 + ? { 59 + ...searchResult, 60 + atprotoMatches: searchResult.atprotoMatches.map( 61 + (match) => 62 + match.did === result.did 63 + ? { ...match, followed: true } 64 + : match, 65 + ), 66 + } 67 + : searchResult, 68 + ), 69 + ); 70 } 71 } 72 }); 73 + 74 + onUpdate( 75 + `Followed ${totalFollowed} of ${selectedUsers.length} users`, 76 + ); 77 } catch (error) { 78 totalFailed += batch.length; 79 + console.error("Batch follow error:", error); 80 } 81 + 82 // Rate limit handling is in the backend 83 } 84 + 85 + const finalMsg = `Successfully followed ${totalFollowed} users${totalFailed > 0 ? `. ${totalFailed} failed.` : ""}`; 86 onUpdate(finalMsg); 87 } catch (error) { 88 console.error("Batch follow error:", error); ··· 96 isFollowing, 97 followSelectedUsers, 98 }; 99 + }
+157 -114
src/hooks/useSearch.ts
··· 1 - import { useState } from 'react'; 2 - import { apiClient } from '../lib/apiClient'; 3 - import { SEARCH_CONFIG } from '../constants/platforms'; 4 - import type { SearchResult, SearchProgress, AtprotoSession } from '../types'; 5 6 function sortSearchResults(results: SearchResult[]): SearchResult[] { 7 return [...results].sort((a, b) => { ··· 9 const aHasMatches = a.atprotoMatches.length > 0 ? 0 : 1; 10 const bHasMatches = b.atprotoMatches.length > 0 ? 0 : 1; 11 if (aHasMatches !== bHasMatches) return aHasMatches - bHasMatches; 12 - 13 // 2. For matched users, sort by highest posts count of their top match 14 if (a.atprotoMatches.length > 0 && b.atprotoMatches.length > 0) { 15 const aTopPosts = a.atprotoMatches[0]?.postCount || 0; 16 const bTopPosts = b.atprotoMatches[0]?.postCount || 0; 17 if (aTopPosts !== bTopPosts) return bTopPosts - aTopPosts; 18 - 19 // 3. Then by followers count 20 const aTopFollowers = a.atprotoMatches[0]?.followerCount || 0; 21 const bTopFollowers = b.atprotoMatches[0]?.followerCount || 0; 22 if (aTopFollowers !== bTopFollowers) return bTopFollowers - aTopFollowers; 23 } 24 - 25 // 4. Username as tiebreaker 26 return a.sourceUser.username.localeCompare(b.sourceUser.username); 27 }); ··· 30 export function useSearch(session: AtprotoSession | null) { 31 const [searchResults, setSearchResults] = useState<SearchResult[]>([]); 32 const [isSearchingAll, setIsSearchingAll] = useState(false); 33 - const [searchProgress, setSearchProgress] = useState<SearchProgress>({ 34 - searched: 0, 35 - found: 0, 36 - total: 0 37 }); 38 - const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set()); 39 40 async function searchAllUsers( 41 resultsToSearch: SearchResult[], 42 onProgressUpdate: (message: string) => void, 43 - onComplete: () => void 44 ) { 45 if (!session || resultsToSearch.length === 0) return; 46 - 47 setIsSearchingAll(true); 48 setSearchProgress({ searched: 0, found: 0, total: resultsToSearch.length }); 49 onProgressUpdate(`Starting search for ${resultsToSearch.length} users...`); 50 - 51 const { BATCH_SIZE, MAX_MATCHES } = SEARCH_CONFIG; 52 let totalSearched = 0; 53 let totalFound = 0; ··· 56 57 for (let i = 0; i < resultsToSearch.length; i += BATCH_SIZE) { 58 if (totalFound >= MAX_MATCHES) { 59 - console.log(`Reached limit of ${MAX_MATCHES} matches. Stopping search.`); 60 - onProgressUpdate(`Search complete. Found ${totalFound} matches out of ${MAX_MATCHES} maximum.`); 61 break; 62 } 63 64 const batch = resultsToSearch.slice(i, i + BATCH_SIZE); 65 - const usernames = batch.map(r => r.sourceUser.username); 66 - 67 // Mark current batch as searching 68 - setSearchResults(prev => prev.map((result, index) => 69 - i <= index && index < i + BATCH_SIZE 70 - ? { ...result, isSearching: true } 71 - : result 72 - )); 73 - 74 try { 75 const data = await apiClient.batchSearchActors(usernames); 76 - 77 // Reset error counter on success 78 consecutiveErrors = 0; 79 - 80 // Process batch results 81 data.results.forEach((result) => { 82 totalSearched++; ··· 85 } 86 }); 87 88 - setSearchProgress({ searched: totalSearched, found: totalFound, total: resultsToSearch.length }); 89 - onProgressUpdate(`Searched ${totalSearched} of ${resultsToSearch.length} users. Found ${totalFound} matches.`); 90 91 // Update results 92 - setSearchResults(prev => prev.map((result, index) => { 93 - const batchResultIndex = index - i; 94 - if (batchResultIndex >= 0 && batchResultIndex < data.results.length) { 95 - const batchResult = data.results[batchResultIndex]; 96 - const newSelectedMatches = new Set<string>(); 97 - 98 - // Auto-select only the first (highest scoring) match 99 - if (batchResult.actors.length > 0) { 100 - newSelectedMatches.add(batchResult.actors[0].did); 101 } 102 103 - return { 104 - ...result, 105 - atprotoMatches: batchResult.actors, 106 - isSearching: false, 107 - error: batchResult.error, 108 - selectedMatches: newSelectedMatches, 109 - }; 110 - } 111 - return result; 112 - })); 113 114 - setSearchResults(prev => prev.map((result, index) => { 115 - const batchResultIndex = index - i; 116 - if (batchResultIndex >= 0 && batchResultIndex < data.results.length) { 117 - const batchResult = data.results[batchResultIndex]; 118 - const newSelectedMatches = new Set<string>(); 119 - 120 - if (batchResult.actors.length > 0) { 121 - newSelectedMatches.add(batchResult.actors[0].did); 122 } 123 - 124 - return { 125 - ...result, 126 - atprotoMatches: batchResult.actors, 127 - isSearching: false, 128 - error: batchResult.error, 129 - selectedMatches: newSelectedMatches, 130 - }; 131 - } 132 - return result; 133 - })); 134 135 if (totalFound >= MAX_MATCHES) { 136 break; 137 } 138 - 139 } catch (error) { 140 - console.error('Batch search error:', error); 141 consecutiveErrors++; 142 - 143 // Mark batch as failed 144 - setSearchResults(prev => prev.map((result, index) => 145 - i <= index && index < i + BATCH_SIZE 146 - ? { ...result, isSearching: false, error: 'Search failed' } 147 - : result 148 - )); 149 - 150 // If we hit rate limits or repeated errors, add exponential backoff 151 if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 152 - const backoffDelay = Math.min(1000 * Math.pow(2, consecutiveErrors - MAX_CONSECUTIVE_ERRORS), 5000); 153 - console.log(`Rate limit detected. Backing off for ${backoffDelay}ms...`); 154 onProgressUpdate(`Rate limit detected. Pausing briefly...`); 155 - await new Promise(resolve => setTimeout(resolve, backoffDelay)); 156 } 157 } 158 } 159 - 160 setIsSearchingAll(false); 161 - onProgressUpdate(`Search complete! Found ${totalFound} matches out of ${totalSearched} users searched.`); 162 onComplete(); 163 } 164 165 function toggleMatchSelection(resultIndex: number, did: string) { 166 - setSearchResults(prev => prev.map((result, index) => { 167 - if (index === resultIndex) { 168 - const newSelectedMatches = new Set(result.selectedMatches); 169 - if (newSelectedMatches.has(did)) { 170 - newSelectedMatches.delete(did); 171 - } else { 172 - newSelectedMatches.add(did); 173 } 174 - return { ...result, selectedMatches: newSelectedMatches }; 175 - } 176 - return result; 177 - })); 178 } 179 180 function toggleExpandResult(index: number) { 181 - setExpandedResults(prev => { 182 const next = new Set(prev); 183 if (next.has(index)) next.delete(index); 184 else next.add(index); ··· 187 } 188 189 function selectAllMatches(onUpdate: (message: string) => void) { 190 - setSearchResults(prev => prev.map(result => { 191 - const newSelectedMatches = new Set<string>(); 192 - if (result.atprotoMatches.length > 0) { 193 - newSelectedMatches.add(result.atprotoMatches[0].did); 194 - } 195 - return { 196 - ...result, 197 - selectedMatches: newSelectedMatches 198 - }; 199 - })); 200 201 - const totalToSelect = searchResults.filter(r => r.atprotoMatches.length > 0).length; 202 onUpdate(`Selected ${totalToSelect} top matches`); 203 } 204 205 function deselectAllMatches(onUpdate: (message: string) => void) { 206 - setSearchResults(prev => prev.map(result => ({ 207 - ...result, 208 - selectedMatches: new Set<string>() 209 - }))); 210 - onUpdate('Cleared all selections'); 211 } 212 213 - const totalSelected = searchResults.reduce((total, result) => 214 - total + (result.selectedMatches?.size || 0), 0 215 ); 216 - 217 - const totalFound = searchResults.filter(r => r.atprotoMatches.length > 0).length; 218 219 return { 220 searchResults, ··· 230 totalSelected, 231 totalFound, 232 }; 233 - }
··· 1 + import { useState } from "react"; 2 + import { apiClient } from "../lib/apiClient"; 3 + import { SEARCH_CONFIG } from "../constants/platforms"; 4 + import type { SearchResult, SearchProgress, AtprotoSession } from "../types"; 5 6 function sortSearchResults(results: SearchResult[]): SearchResult[] { 7 return [...results].sort((a, b) => { ··· 9 const aHasMatches = a.atprotoMatches.length > 0 ? 0 : 1; 10 const bHasMatches = b.atprotoMatches.length > 0 ? 0 : 1; 11 if (aHasMatches !== bHasMatches) return aHasMatches - bHasMatches; 12 + 13 // 2. For matched users, sort by highest posts count of their top match 14 if (a.atprotoMatches.length > 0 && b.atprotoMatches.length > 0) { 15 const aTopPosts = a.atprotoMatches[0]?.postCount || 0; 16 const bTopPosts = b.atprotoMatches[0]?.postCount || 0; 17 if (aTopPosts !== bTopPosts) return bTopPosts - aTopPosts; 18 + 19 // 3. Then by followers count 20 const aTopFollowers = a.atprotoMatches[0]?.followerCount || 0; 21 const bTopFollowers = b.atprotoMatches[0]?.followerCount || 0; 22 if (aTopFollowers !== bTopFollowers) return bTopFollowers - aTopFollowers; 23 } 24 + 25 // 4. Username as tiebreaker 26 return a.sourceUser.username.localeCompare(b.sourceUser.username); 27 }); ··· 30 export function useSearch(session: AtprotoSession | null) { 31 const [searchResults, setSearchResults] = useState<SearchResult[]>([]); 32 const [isSearchingAll, setIsSearchingAll] = useState(false); 33 + const [searchProgress, setSearchProgress] = useState<SearchProgress>({ 34 + searched: 0, 35 + found: 0, 36 + total: 0, 37 }); 38 + const [expandedResults, setExpandedResults] = useState<Set<number>>( 39 + new Set(), 40 + ); 41 42 async function searchAllUsers( 43 resultsToSearch: SearchResult[], 44 onProgressUpdate: (message: string) => void, 45 + onComplete: () => void, 46 ) { 47 if (!session || resultsToSearch.length === 0) return; 48 + 49 setIsSearchingAll(true); 50 setSearchProgress({ searched: 0, found: 0, total: resultsToSearch.length }); 51 onProgressUpdate(`Starting search for ${resultsToSearch.length} users...`); 52 + 53 const { BATCH_SIZE, MAX_MATCHES } = SEARCH_CONFIG; 54 let totalSearched = 0; 55 let totalFound = 0; ··· 58 59 for (let i = 0; i < resultsToSearch.length; i += BATCH_SIZE) { 60 if (totalFound >= MAX_MATCHES) { 61 + console.log( 62 + `Reached limit of ${MAX_MATCHES} matches. Stopping search.`, 63 + ); 64 + onProgressUpdate( 65 + `Search complete. Found ${totalFound} matches out of ${MAX_MATCHES} maximum.`, 66 + ); 67 break; 68 } 69 70 const batch = resultsToSearch.slice(i, i + BATCH_SIZE); 71 + const usernames = batch.map((r) => r.sourceUser.username); 72 + 73 // Mark current batch as searching 74 + setSearchResults((prev) => 75 + prev.map((result, index) => 76 + i <= index && index < i + BATCH_SIZE 77 + ? { ...result, isSearching: true } 78 + : result, 79 + ), 80 + ); 81 + 82 try { 83 const data = await apiClient.batchSearchActors(usernames); 84 + 85 // Reset error counter on success 86 consecutiveErrors = 0; 87 + 88 // Process batch results 89 data.results.forEach((result) => { 90 totalSearched++; ··· 93 } 94 }); 95 96 + setSearchProgress({ 97 + searched: totalSearched, 98 + found: totalFound, 99 + total: resultsToSearch.length, 100 + }); 101 + onProgressUpdate( 102 + `Searched ${totalSearched} of ${resultsToSearch.length} users. Found ${totalFound} matches.`, 103 + ); 104 105 // Update results 106 + setSearchResults((prev) => 107 + prev.map((result, index) => { 108 + const batchResultIndex = index - i; 109 + if ( 110 + batchResultIndex >= 0 && 111 + batchResultIndex < data.results.length 112 + ) { 113 + const batchResult = data.results[batchResultIndex]; 114 + const newSelectedMatches = new Set<string>(); 115 + 116 + // Auto-select only the first (highest scoring) match 117 + if (batchResult.actors.length > 0) { 118 + newSelectedMatches.add(batchResult.actors[0].did); 119 + } 120 + 121 + return { 122 + ...result, 123 + atprotoMatches: batchResult.actors, 124 + isSearching: false, 125 + error: batchResult.error, 126 + selectedMatches: newSelectedMatches, 127 + }; 128 } 129 + return result; 130 + }), 131 + ); 132 133 + setSearchResults((prev) => 134 + prev.map((result, index) => { 135 + const batchResultIndex = index - i; 136 + if ( 137 + batchResultIndex >= 0 && 138 + batchResultIndex < data.results.length 139 + ) { 140 + const batchResult = data.results[batchResultIndex]; 141 + const newSelectedMatches = new Set<string>(); 142 143 + if (batchResult.actors.length > 0) { 144 + newSelectedMatches.add(batchResult.actors[0].did); 145 + } 146 + 147 + return { 148 + ...result, 149 + atprotoMatches: batchResult.actors, 150 + isSearching: false, 151 + error: batchResult.error, 152 + selectedMatches: newSelectedMatches, 153 + }; 154 } 155 + return result; 156 + }), 157 + ); 158 159 if (totalFound >= MAX_MATCHES) { 160 break; 161 } 162 } catch (error) { 163 + console.error("Batch search error:", error); 164 consecutiveErrors++; 165 + 166 // Mark batch as failed 167 + setSearchResults((prev) => 168 + prev.map((result, index) => 169 + i <= index && index < i + BATCH_SIZE 170 + ? { ...result, isSearching: false, error: "Search failed" } 171 + : result, 172 + ), 173 + ); 174 + 175 // If we hit rate limits or repeated errors, add exponential backoff 176 if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 177 + const backoffDelay = Math.min( 178 + 1000 * Math.pow(2, consecutiveErrors - MAX_CONSECUTIVE_ERRORS), 179 + 5000, 180 + ); 181 + console.log( 182 + `Rate limit detected. Backing off for ${backoffDelay}ms...`, 183 + ); 184 onProgressUpdate(`Rate limit detected. Pausing briefly...`); 185 + await new Promise((resolve) => setTimeout(resolve, backoffDelay)); 186 } 187 } 188 } 189 + 190 setIsSearchingAll(false); 191 + onProgressUpdate( 192 + `Search complete! Found ${totalFound} matches out of ${totalSearched} users searched.`, 193 + ); 194 onComplete(); 195 } 196 197 function toggleMatchSelection(resultIndex: number, did: string) { 198 + setSearchResults((prev) => 199 + prev.map((result, index) => { 200 + if (index === resultIndex) { 201 + const newSelectedMatches = new Set(result.selectedMatches); 202 + if (newSelectedMatches.has(did)) { 203 + newSelectedMatches.delete(did); 204 + } else { 205 + newSelectedMatches.add(did); 206 + } 207 + return { ...result, selectedMatches: newSelectedMatches }; 208 } 209 + return result; 210 + }), 211 + ); 212 } 213 214 function toggleExpandResult(index: number) { 215 + setExpandedResults((prev) => { 216 const next = new Set(prev); 217 if (next.has(index)) next.delete(index); 218 else next.add(index); ··· 221 } 222 223 function selectAllMatches(onUpdate: (message: string) => void) { 224 + setSearchResults((prev) => 225 + prev.map((result) => { 226 + const newSelectedMatches = new Set<string>(); 227 + if (result.atprotoMatches.length > 0) { 228 + newSelectedMatches.add(result.atprotoMatches[0].did); 229 + } 230 + return { 231 + ...result, 232 + selectedMatches: newSelectedMatches, 233 + }; 234 + }), 235 + ); 236 237 + const totalToSelect = searchResults.filter( 238 + (r) => r.atprotoMatches.length > 0, 239 + ).length; 240 onUpdate(`Selected ${totalToSelect} top matches`); 241 } 242 243 function deselectAllMatches(onUpdate: (message: string) => void) { 244 + setSearchResults((prev) => 245 + prev.map((result) => ({ 246 + ...result, 247 + selectedMatches: new Set<string>(), 248 + })), 249 + ); 250 + onUpdate("Cleared all selections"); 251 } 252 253 + const totalSelected = searchResults.reduce( 254 + (total, result) => total + (result.selectedMatches?.size || 0), 255 + 0, 256 ); 257 + 258 + const totalFound = searchResults.filter( 259 + (r) => r.atprotoMatches.length > 0, 260 + ).length; 261 262 return { 263 searchResults, ··· 273 totalSelected, 274 totalFound, 275 }; 276 + }
+12 -12
src/hooks/useTheme.ts
··· 1 - import { useState, useEffect } from 'react'; 2 3 export function useTheme() { 4 const [isDark, setIsDark] = useState(() => { 5 // Check localStorage first, then system preference 6 - const stored = localStorage.getItem('theme'); 7 - if (stored) return stored === 'dark'; 8 - return window.matchMedia('(prefers-color-scheme: dark)').matches; 9 }); 10 11 const [reducedMotion, setReducedMotion] = useState(() => { 12 - return window.matchMedia('(prefers-reduced-motion: reduce)').matches; 13 }); 14 15 useEffect(() => { 16 // Apply theme to document 17 if (isDark) { 18 - document.documentElement.classList.add('dark'); 19 } else { 20 - document.documentElement.classList.remove('dark'); 21 } 22 - localStorage.setItem('theme', isDark ? 'dark' : 'light'); 23 }, [isDark]); 24 25 useEffect(() => { 26 // Listen for system motion preference changes 27 - const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); 28 const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); 29 - mediaQuery.addEventListener('change', handler); 30 - return () => mediaQuery.removeEventListener('change', handler); 31 }, []); 32 33 const toggleTheme = () => setIsDark(!isDark); 34 const toggleMotion = () => setReducedMotion(!reducedMotion); 35 36 return { isDark, reducedMotion, toggleTheme, toggleMotion }; 37 - }
··· 1 + import { useState, useEffect } from "react"; 2 3 export function useTheme() { 4 const [isDark, setIsDark] = useState(() => { 5 // Check localStorage first, then system preference 6 + const stored = localStorage.getItem("theme"); 7 + if (stored) return stored === "dark"; 8 + return window.matchMedia("(prefers-color-scheme: dark)").matches; 9 }); 10 11 const [reducedMotion, setReducedMotion] = useState(() => { 12 + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; 13 }); 14 15 useEffect(() => { 16 // Apply theme to document 17 if (isDark) { 18 + document.documentElement.classList.add("dark"); 19 } else { 20 + document.documentElement.classList.remove("dark"); 21 } 22 + localStorage.setItem("theme", isDark ? "dark" : "light"); 23 }, [isDark]); 24 25 useEffect(() => { 26 // Listen for system motion preference changes 27 + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); 28 const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); 29 + mediaQuery.addEventListener("change", handler); 30 + return () => mediaQuery.removeEventListener("change", handler); 31 }, []); 32 33 const toggleTheme = () => setIsDark(!isDark); 34 const toggleMotion = () => setReducedMotion(!reducedMotion); 35 36 return { isDark, reducedMotion, toggleTheme, toggleMotion }; 37 + }
+4 -4
src/lib/apiClient/index.ts
··· 1 - import { isLocalMockMode } from '../config'; 2 3 // Import both clients 4 - import { apiClient as realApiClient } from './realApiClient'; 5 - import { mockApiClient } from './mockApiClient'; 6 7 // Export the appropriate client 8 export const apiClient = isLocalMockMode() ? mockApiClient : realApiClient; 9 10 // Also export both for explicit usage 11 - export { realApiClient, mockApiClient };
··· 1 + import { isLocalMockMode } from "../config"; 2 3 // Import both clients 4 + import { apiClient as realApiClient } from "./realApiClient"; 5 + import { mockApiClient } from "./mockApiClient"; 6 7 // Export the appropriate client 8 export const apiClient = isLocalMockMode() ? mockApiClient : realApiClient; 9 10 // Also export both for explicit usage 11 + export { realApiClient, mockApiClient };
+82 -65
src/lib/apiClient/mockApiClient.ts
··· 1 - import type { 2 - AtprotoSession, 3 - BatchSearchResult, 4 BatchFollowResult, 5 SearchResult, 6 - SaveResultsResponse 7 - } from '../../types'; 8 9 // Mock user data for testing 10 const MOCK_SESSION: AtprotoSession = { 11 - did: 'did:plc:mock123', 12 - handle: 'developer.bsky.social', 13 - displayName: 'Local Developer', 14 avatar: undefined, 15 - description: 'Testing ATlast locally' 16 }; 17 18 // Generate mock Bluesky matches 19 function generateMockMatches(username: string): any[] { 20 - const numMatches = Math.random() < 0.7 ? Math.floor(Math.random() * 3) + 1 : 0; 21 - 22 return Array.from({ length: numMatches }, (_, i) => ({ 23 did: `did:plc:mock${username}${i}`, 24 handle: `${username}.bsky.social`, 25 displayName: username.charAt(0).toUpperCase() + username.slice(1), 26 avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}${i}`, 27 - matchScore: 100 - (i * 20), 28 description: `Mock profile for ${username}`, 29 postCount: Math.floor(Math.random() * 1000), 30 followerCount: Math.floor(Math.random() * 5000), ··· 32 } 33 34 // Simulate network delay 35 - const delay = (ms: number = 500) => new Promise(resolve => setTimeout(resolve, ms)); 36 37 export const mockApiClient = { 38 async startOAuth(handle: string): Promise<{ url: string }> { 39 await delay(300); 40 - console.log('[MOCK] Starting OAuth for:', handle); 41 // In mock mode, just return to home immediately 42 - return { url: window.location.origin + '/?session=mock' }; 43 }, 44 45 async getSession(): Promise<AtprotoSession> { 46 await delay(200); 47 - console.log('[MOCK] Getting session'); 48 - 49 // Check if user has "logged in" via mock OAuth 50 const params = new URLSearchParams(window.location.search); 51 - if (params.get('session') === 'mock') { 52 return MOCK_SESSION; 53 } 54 - 55 // Check localStorage for mock session 56 - const mockSession = localStorage.getItem('mock_session'); 57 if (mockSession) { 58 return JSON.parse(mockSession); 59 } 60 - 61 - throw new Error('No mock session'); 62 }, 63 64 async logout(): Promise<void> { 65 await delay(200); 66 - console.log('[MOCK] Logging out'); 67 - localStorage.removeItem('mock_session'); 68 - localStorage.removeItem('mock_uploads'); 69 }, 70 71 async getUploads(): Promise<{ uploads: any[] }> { 72 await delay(300); 73 - console.log('[MOCK] Getting uploads'); 74 - 75 - const mockUploads = localStorage.getItem('mock_uploads'); 76 if (mockUploads) { 77 return { uploads: JSON.parse(mockUploads) }; 78 } 79 - 80 return { uploads: [] }; 81 }, 82 83 - async getUploadDetails(uploadId: string, page: number = 1, pageSize: number = 50): Promise<{ 84 results: SearchResult[]; 85 pagination?: any; 86 }> { 87 await delay(500); 88 - console.log('[MOCK] Getting upload details:', uploadId); 89 - 90 const mockData = localStorage.getItem(`mock_upload_${uploadId}`); 91 if (mockData) { 92 const results = JSON.parse(mockData); 93 return { results }; 94 } 95 - 96 return { results: [] }; 97 }, 98 99 - async getAllUploadDetails(uploadId: string): Promise<{ results: SearchResult[] }> { 100 return this.getUploadDetails(uploadId); 101 }, 102 103 - async batchSearchActors(usernames: string[]): Promise<{ results: BatchSearchResult[] }> { 104 await delay(800); // Simulate API delay 105 - console.log('[MOCK] Searching for:', usernames); 106 - 107 - const results: BatchSearchResult[] = usernames.map(username => ({ 108 username, 109 actors: generateMockMatches(username), 110 - error: undefined 111 })); 112 - 113 return { results }; 114 }, 115 ··· 121 results: BatchFollowResult[]; 122 }> { 123 await delay(1000); 124 - console.log('[MOCK] Following users:', dids); 125 - 126 - const results: BatchFollowResult[] = dids.map(did => ({ 127 did, 128 success: true, 129 - error: null 130 })); 131 - 132 return { 133 success: true, 134 total: dids.length, 135 succeeded: dids.length, 136 failed: 0, 137 - results 138 }; 139 }, 140 141 async saveResults( 142 - uploadId: string, 143 - sourcePlatform: string, 144 - results: SearchResult[] 145 ): Promise<SaveResultsResponse> { 146 await delay(500); 147 - console.log('[MOCK] Saving results:', { uploadId, sourcePlatform, count: results.length }); 148 - 149 // Save to localStorage 150 localStorage.setItem(`mock_upload_${uploadId}`, JSON.stringify(results)); 151 - 152 // Add to uploads list 153 - const uploads = JSON.parse(localStorage.getItem('mock_uploads') || '[]'); 154 - const matchedUsers = results.filter(r => r.atprotoMatches.length > 0).length; 155 - 156 uploads.unshift({ 157 uploadId, 158 sourcePlatform, 159 createdAt: new Date().toISOString(), 160 totalUsers: results.length, 161 matchedUsers, 162 - unmatchedUsers: results.length - matchedUsers 163 }); 164 - 165 - localStorage.setItem('mock_uploads', JSON.stringify(uploads)); 166 - 167 return { 168 success: true, 169 uploadId, 170 totalUsers: results.length, 171 matchedUsers, 172 - unmatchedUsers: results.length - matchedUsers 173 }; 174 }, 175 176 cache: { 177 - clear: () => console.log('[MOCK] Cache cleared'), 178 - invalidate: (key: string) => console.log('[MOCK] Cache invalidated:', key), 179 - invalidatePattern: (pattern: string) => console.log('[MOCK] Cache pattern invalidated:', pattern), 180 - } 181 - }
··· 1 + import type { 2 + AtprotoSession, 3 + BatchSearchResult, 4 BatchFollowResult, 5 SearchResult, 6 + SaveResultsResponse, 7 + } from "../../types"; 8 9 // Mock user data for testing 10 const MOCK_SESSION: AtprotoSession = { 11 + did: "did:plc:mock123", 12 + handle: "developer.bsky.social", 13 + displayName: "Local Developer", 14 avatar: undefined, 15 + description: "Testing ATlast locally", 16 }; 17 18 // Generate mock Bluesky matches 19 function generateMockMatches(username: string): any[] { 20 + const numMatches = 21 + Math.random() < 0.7 ? Math.floor(Math.random() * 3) + 1 : 0; 22 + 23 return Array.from({ length: numMatches }, (_, i) => ({ 24 did: `did:plc:mock${username}${i}`, 25 handle: `${username}.bsky.social`, 26 displayName: username.charAt(0).toUpperCase() + username.slice(1), 27 avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}${i}`, 28 + matchScore: 100 - i * 20, 29 description: `Mock profile for ${username}`, 30 postCount: Math.floor(Math.random() * 1000), 31 followerCount: Math.floor(Math.random() * 5000), ··· 33 } 34 35 // Simulate network delay 36 + const delay = (ms: number = 500) => 37 + new Promise((resolve) => setTimeout(resolve, ms)); 38 39 export const mockApiClient = { 40 async startOAuth(handle: string): Promise<{ url: string }> { 41 await delay(300); 42 + console.log("[MOCK] Starting OAuth for:", handle); 43 // In mock mode, just return to home immediately 44 + return { url: window.location.origin + "/?session=mock" }; 45 }, 46 47 async getSession(): Promise<AtprotoSession> { 48 await delay(200); 49 + console.log("[MOCK] Getting session"); 50 + 51 // Check if user has "logged in" via mock OAuth 52 const params = new URLSearchParams(window.location.search); 53 + if (params.get("session") === "mock") { 54 return MOCK_SESSION; 55 } 56 + 57 // Check localStorage for mock session 58 + const mockSession = localStorage.getItem("mock_session"); 59 if (mockSession) { 60 return JSON.parse(mockSession); 61 } 62 + 63 + throw new Error("No mock session"); 64 }, 65 66 async logout(): Promise<void> { 67 await delay(200); 68 + console.log("[MOCK] Logging out"); 69 + localStorage.removeItem("mock_session"); 70 + localStorage.removeItem("mock_uploads"); 71 }, 72 73 async getUploads(): Promise<{ uploads: any[] }> { 74 await delay(300); 75 + console.log("[MOCK] Getting uploads"); 76 + 77 + const mockUploads = localStorage.getItem("mock_uploads"); 78 if (mockUploads) { 79 return { uploads: JSON.parse(mockUploads) }; 80 } 81 + 82 return { uploads: [] }; 83 }, 84 85 + async getUploadDetails( 86 + uploadId: string, 87 + page: number = 1, 88 + pageSize: number = 50, 89 + ): Promise<{ 90 results: SearchResult[]; 91 pagination?: any; 92 }> { 93 await delay(500); 94 + console.log("[MOCK] Getting upload details:", uploadId); 95 + 96 const mockData = localStorage.getItem(`mock_upload_${uploadId}`); 97 if (mockData) { 98 const results = JSON.parse(mockData); 99 return { results }; 100 } 101 + 102 return { results: [] }; 103 }, 104 105 + async getAllUploadDetails( 106 + uploadId: string, 107 + ): Promise<{ results: SearchResult[] }> { 108 return this.getUploadDetails(uploadId); 109 }, 110 111 + async batchSearchActors( 112 + usernames: string[], 113 + ): Promise<{ results: BatchSearchResult[] }> { 114 await delay(800); // Simulate API delay 115 + console.log("[MOCK] Searching for:", usernames); 116 + 117 + const results: BatchSearchResult[] = usernames.map((username) => ({ 118 username, 119 actors: generateMockMatches(username), 120 + error: undefined, 121 })); 122 + 123 return { results }; 124 }, 125 ··· 131 results: BatchFollowResult[]; 132 }> { 133 await delay(1000); 134 + console.log("[MOCK] Following users:", dids); 135 + 136 + const results: BatchFollowResult[] = dids.map((did) => ({ 137 did, 138 success: true, 139 + error: null, 140 })); 141 + 142 return { 143 success: true, 144 total: dids.length, 145 succeeded: dids.length, 146 failed: 0, 147 + results, 148 }; 149 }, 150 151 async saveResults( 152 + uploadId: string, 153 + sourcePlatform: string, 154 + results: SearchResult[], 155 ): Promise<SaveResultsResponse> { 156 await delay(500); 157 + console.log("[MOCK] Saving results:", { 158 + uploadId, 159 + sourcePlatform, 160 + count: results.length, 161 + }); 162 + 163 // Save to localStorage 164 localStorage.setItem(`mock_upload_${uploadId}`, JSON.stringify(results)); 165 + 166 // Add to uploads list 167 + const uploads = JSON.parse(localStorage.getItem("mock_uploads") || "[]"); 168 + const matchedUsers = results.filter( 169 + (r) => r.atprotoMatches.length > 0, 170 + ).length; 171 + 172 uploads.unshift({ 173 uploadId, 174 sourcePlatform, 175 createdAt: new Date().toISOString(), 176 totalUsers: results.length, 177 matchedUsers, 178 + unmatchedUsers: results.length - matchedUsers, 179 }); 180 + 181 + localStorage.setItem("mock_uploads", JSON.stringify(uploads)); 182 + 183 return { 184 success: true, 185 uploadId, 186 totalUsers: results.length, 187 matchedUsers, 188 + unmatchedUsers: results.length - matchedUsers, 189 }; 190 }, 191 192 cache: { 193 + clear: () => console.log("[MOCK] Cache cleared"), 194 + invalidate: (key: string) => console.log("[MOCK] Cache invalidated:", key), 195 + invalidatePattern: (pattern: string) => 196 + console.log("[MOCK] Cache pattern invalidated:", pattern), 197 + }, 198 + };
+109 -81
src/lib/apiClient/realApiClient.ts
··· 1 - import type { AtprotoSession, BatchSearchResult, BatchFollowResult, SaveResultsResponse, SearchResult } from '../../types'; 2 3 // Client-side cache with TTL 4 interface CacheEntry<T> { ··· 66 // OAuth and Authentication 67 async startOAuth(handle: string): Promise<{ url: string }> { 68 const currentOrigin = window.location.origin; 69 - 70 - const res = await fetch('/.netlify/functions/oauth-start', { 71 - method: 'POST', 72 - headers: { 'Content-Type': 'application/json' }, 73 - body: JSON.stringify({ 74 login_hint: handle, 75 - origin: currentOrigin 76 }), 77 }); 78 79 if (!res.ok) { 80 const errorData = await res.json(); 81 - throw new Error(errorData.error || 'Failed to start OAuth flow'); 82 } 83 84 return res.json(); 85 }, 86 87 - async getSession(): Promise<{ did: string; handle: string; displayName?: string; avatar?: string; description?: string }> { 88 // Check cache first 89 - const cacheKey = 'session'; 90 const cached = cache.get<AtprotoSession>(cacheKey); 91 if (cached) { 92 - console.log('Returning cached session'); 93 return cached; 94 } 95 96 - const res = await fetch('/.netlify/functions/session', { 97 - credentials: 'include' 98 }); 99 100 if (!res.ok) { 101 - throw new Error('No valid session'); 102 } 103 104 const data = await res.json(); 105 - 106 // Cache the session data for 5 minutes 107 cache.set(cacheKey, data, 5 * 60 * 1000); 108 - 109 return data; 110 }, 111 ··· 116 }, 117 118 async logout(): Promise<void> { 119 - const res = await fetch('/.netlify/functions/logout', { 120 - method: 'POST', 121 - credentials: 'include' 122 }); 123 124 if (!res.ok) { 125 - throw new Error('Logout failed'); 126 } 127 128 // Clear all caches on logout ··· 141 }>; 142 }> { 143 // Check cache first 144 - const cacheKey = 'uploads'; 145 const cached = cache.get<any>(cacheKey, 2 * 60 * 1000); // 2 minute cache for uploads list 146 if (cached) { 147 - console.log('Returning cached uploads'); 148 return cached; 149 } 150 151 - const res = await fetch('/.netlify/functions/get-uploads', { 152 - credentials: 'include' 153 }); 154 155 if (!res.ok) { 156 - throw new Error('Failed to fetch uploads'); 157 } 158 159 const data = await res.json(); 160 - 161 // Cache uploads list for 2 minutes 162 cache.set(cacheKey, data, 2 * 60 * 1000); 163 - 164 return data; 165 }, 166 167 async getUploadDetails( 168 - uploadId: string, 169 - page: number = 1, 170 - pageSize: number = 50 171 ): Promise<{ 172 results: SearchResult[]; 173 pagination?: { ··· 183 const cacheKey = `upload-details-${uploadId}-p${page}-s${pageSize}`; 184 const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 185 if (cached) { 186 - console.log('Returning cached upload details for', uploadId, 'page', page); 187 return cached; 188 } 189 190 const res = await fetch( 191 - `/.netlify/functions/get-upload-details?uploadId=${uploadId}&page=${page}&pageSize=${pageSize}`, 192 - { credentials: 'include' } 193 ); 194 195 if (!res.ok) { 196 - throw new Error('Failed to fetch upload details'); 197 } 198 199 const data = await res.json(); 200 - 201 // Cache upload details page for 10 minutes 202 cache.set(cacheKey, data, 10 * 60 * 1000); 203 - 204 return data; 205 }, 206 207 // Helper to load all pages (for backwards compatibility) 208 - async getAllUploadDetails(uploadId: string): Promise<{ results: SearchResult[] }> { 209 const firstPage = await this.getUploadDetails(uploadId, 1, 100); 210 - 211 if (!firstPage.pagination || firstPage.pagination.totalPages === 1) { 212 return { results: firstPage.results }; 213 } ··· 215 // Load remaining pages 216 const allResults = [...firstPage.results]; 217 const promises = []; 218 - 219 for (let page = 2; page <= firstPage.pagination.totalPages; page++) { 220 promises.push(this.getUploadDetails(uploadId, page, 100)); 221 } ··· 229 }, 230 231 // Search Operations 232 - async batchSearchActors(usernames: string[]): Promise<{ results: BatchSearchResult[] }> { 233 // Create cache key from sorted usernames (so order doesn't matter) 234 - const cacheKey = `search-${usernames.slice().sort().join(',')}`; 235 const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 236 if (cached) { 237 - console.log('Returning cached search results for', usernames.length, 'users'); 238 return cached; 239 } 240 241 - const res = await fetch('/.netlify/functions/batch-search-actors', { 242 - method: 'POST', 243 - credentials: 'include', 244 - headers: { 'Content-Type': 'application/json' }, 245 - body: JSON.stringify({ usernames }) 246 }); 247 248 if (!res.ok) { ··· 250 } 251 252 const data = await res.json(); 253 - 254 // Cache search results for 10 minutes 255 cache.set(cacheKey, data, 10 * 60 * 1000); 256 - 257 return data; 258 }, 259 260 // Follow Operations 261 - async batchFollowUsers(dids: string[]): Promise<{ 262 success: boolean; 263 total: number; 264 succeeded: number; 265 failed: number; 266 results: BatchFollowResult[]; 267 }> { 268 - const res = await fetch('/.netlify/functions/batch-follow-users', { 269 - method: 'POST', 270 - credentials: 'include', 271 - headers: { 'Content-Type': 'application/json' }, 272 body: JSON.stringify({ dids }), 273 }); 274 275 if (!res.ok) { 276 - throw new Error('Batch follow failed'); 277 } 278 279 const data = await res.json(); 280 - 281 // Invalidate uploads cache after following 282 - cache.invalidate('uploads'); 283 - cache.invalidatePattern('upload-details'); 284 - 285 return data; 286 }, 287 288 // Save Results 289 async saveResults( 290 - uploadId: string, 291 - sourcePlatform: string, 292 - results: SearchResult[] 293 ): Promise<SaveResultsResponse | null> { 294 try { 295 const resultsToSave = results 296 - .filter(r => !r.isSearching) 297 - .map(r => ({ 298 sourceUser: r.sourceUser, 299 - atprotoMatches: r.atprotoMatches || [] 300 })); 301 - 302 console.log(`Saving ${resultsToSave.length} results in background...`); 303 - 304 - const res = await fetch('/.netlify/functions/save-results', { 305 - method: 'POST', 306 - credentials: 'include', 307 - headers: { 'Content-Type': 'application/json' }, 308 body: JSON.stringify({ 309 uploadId, 310 sourcePlatform, 311 - results: resultsToSave 312 - }) 313 }); 314 315 if (res.ok) { 316 const data = await res.json(); 317 console.log(`Successfully saved ${data.matchedUsers} matches`); 318 - 319 // Invalidate caches after saving 320 - cache.invalidate('uploads'); 321 - cache.invalidatePattern('upload-details'); 322 - 323 return data; 324 } else { 325 - console.error('Failed to save results:', res.status, await res.text()); 326 return null; 327 } 328 } catch (error) { 329 - console.error('Error saving results (will continue in background):', error); 330 return null; 331 } 332 }, ··· 336 clear: () => cache.clear(), 337 invalidate: (key: string) => cache.invalidate(key), 338 invalidatePattern: (pattern: string) => cache.invalidatePattern(pattern), 339 - } 340 - };
··· 1 + import type { 2 + AtprotoSession, 3 + BatchSearchResult, 4 + BatchFollowResult, 5 + SaveResultsResponse, 6 + SearchResult, 7 + } from "../../types"; 8 9 // Client-side cache with TTL 10 interface CacheEntry<T> { ··· 72 // OAuth and Authentication 73 async startOAuth(handle: string): Promise<{ url: string }> { 74 const currentOrigin = window.location.origin; 75 + 76 + const res = await fetch("/.netlify/functions/oauth-start", { 77 + method: "POST", 78 + headers: { "Content-Type": "application/json" }, 79 + body: JSON.stringify({ 80 login_hint: handle, 81 + origin: currentOrigin, 82 }), 83 }); 84 85 if (!res.ok) { 86 const errorData = await res.json(); 87 + throw new Error(errorData.error || "Failed to start OAuth flow"); 88 } 89 90 return res.json(); 91 }, 92 93 + async getSession(): Promise<{ 94 + did: string; 95 + handle: string; 96 + displayName?: string; 97 + avatar?: string; 98 + description?: string; 99 + }> { 100 // Check cache first 101 + const cacheKey = "session"; 102 const cached = cache.get<AtprotoSession>(cacheKey); 103 if (cached) { 104 + console.log("Returning cached session"); 105 return cached; 106 } 107 108 + const res = await fetch("/.netlify/functions/session", { 109 + credentials: "include", 110 }); 111 112 if (!res.ok) { 113 + throw new Error("No valid session"); 114 } 115 116 const data = await res.json(); 117 + 118 // Cache the session data for 5 minutes 119 cache.set(cacheKey, data, 5 * 60 * 1000); 120 + 121 return data; 122 }, 123 ··· 128 }, 129 130 async logout(): Promise<void> { 131 + const res = await fetch("/.netlify/functions/logout", { 132 + method: "POST", 133 + credentials: "include", 134 }); 135 136 if (!res.ok) { 137 + throw new Error("Logout failed"); 138 } 139 140 // Clear all caches on logout ··· 153 }>; 154 }> { 155 // Check cache first 156 + const cacheKey = "uploads"; 157 const cached = cache.get<any>(cacheKey, 2 * 60 * 1000); // 2 minute cache for uploads list 158 if (cached) { 159 + console.log("Returning cached uploads"); 160 return cached; 161 } 162 163 + const res = await fetch("/.netlify/functions/get-uploads", { 164 + credentials: "include", 165 }); 166 167 if (!res.ok) { 168 + throw new Error("Failed to fetch uploads"); 169 } 170 171 const data = await res.json(); 172 + 173 // Cache uploads list for 2 minutes 174 cache.set(cacheKey, data, 2 * 60 * 1000); 175 + 176 return data; 177 }, 178 179 async getUploadDetails( 180 + uploadId: string, 181 + page: number = 1, 182 + pageSize: number = 50, 183 ): Promise<{ 184 results: SearchResult[]; 185 pagination?: { ··· 195 const cacheKey = `upload-details-${uploadId}-p${page}-s${pageSize}`; 196 const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 197 if (cached) { 198 + console.log( 199 + "Returning cached upload details for", 200 + uploadId, 201 + "page", 202 + page, 203 + ); 204 return cached; 205 } 206 207 const res = await fetch( 208 + `/.netlify/functions/get-upload-details?uploadId=${uploadId}&page=${page}&pageSize=${pageSize}`, 209 + { credentials: "include" }, 210 ); 211 212 if (!res.ok) { 213 + throw new Error("Failed to fetch upload details"); 214 } 215 216 const data = await res.json(); 217 + 218 // Cache upload details page for 10 minutes 219 cache.set(cacheKey, data, 10 * 60 * 1000); 220 + 221 return data; 222 }, 223 224 // Helper to load all pages (for backwards compatibility) 225 + async getAllUploadDetails( 226 + uploadId: string, 227 + ): Promise<{ results: SearchResult[] }> { 228 const firstPage = await this.getUploadDetails(uploadId, 1, 100); 229 + 230 if (!firstPage.pagination || firstPage.pagination.totalPages === 1) { 231 return { results: firstPage.results }; 232 } ··· 234 // Load remaining pages 235 const allResults = [...firstPage.results]; 236 const promises = []; 237 + 238 for (let page = 2; page <= firstPage.pagination.totalPages; page++) { 239 promises.push(this.getUploadDetails(uploadId, page, 100)); 240 } ··· 248 }, 249 250 // Search Operations 251 + async batchSearchActors( 252 + usernames: string[], 253 + ): Promise<{ results: BatchSearchResult[] }> { 254 // Create cache key from sorted usernames (so order doesn't matter) 255 + const cacheKey = `search-${usernames.slice().sort().join(",")}`; 256 const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 257 if (cached) { 258 + console.log( 259 + "Returning cached search results for", 260 + usernames.length, 261 + "users", 262 + ); 263 return cached; 264 } 265 266 + const res = await fetch("/.netlify/functions/batch-search-actors", { 267 + method: "POST", 268 + credentials: "include", 269 + headers: { "Content-Type": "application/json" }, 270 + body: JSON.stringify({ usernames }), 271 }); 272 273 if (!res.ok) { ··· 275 } 276 277 const data = await res.json(); 278 + 279 // Cache search results for 10 minutes 280 cache.set(cacheKey, data, 10 * 60 * 1000); 281 + 282 return data; 283 }, 284 285 // Follow Operations 286 + async batchFollowUsers(dids: string[]): Promise<{ 287 success: boolean; 288 total: number; 289 succeeded: number; 290 failed: number; 291 results: BatchFollowResult[]; 292 }> { 293 + const res = await fetch("/.netlify/functions/batch-follow-users", { 294 + method: "POST", 295 + credentials: "include", 296 + headers: { "Content-Type": "application/json" }, 297 body: JSON.stringify({ dids }), 298 }); 299 300 if (!res.ok) { 301 + throw new Error("Batch follow failed"); 302 } 303 304 const data = await res.json(); 305 + 306 // Invalidate uploads cache after following 307 + cache.invalidate("uploads"); 308 + cache.invalidatePattern("upload-details"); 309 + 310 return data; 311 }, 312 313 // Save Results 314 async saveResults( 315 + uploadId: string, 316 + sourcePlatform: string, 317 + results: SearchResult[], 318 ): Promise<SaveResultsResponse | null> { 319 try { 320 const resultsToSave = results 321 + .filter((r) => !r.isSearching) 322 + .map((r) => ({ 323 sourceUser: r.sourceUser, 324 + atprotoMatches: r.atprotoMatches || [], 325 })); 326 + 327 console.log(`Saving ${resultsToSave.length} results in background...`); 328 + 329 + const res = await fetch("/.netlify/functions/save-results", { 330 + method: "POST", 331 + credentials: "include", 332 + headers: { "Content-Type": "application/json" }, 333 body: JSON.stringify({ 334 uploadId, 335 sourcePlatform, 336 + results: resultsToSave, 337 + }), 338 }); 339 340 if (res.ok) { 341 const data = await res.json(); 342 console.log(`Successfully saved ${data.matchedUsers} matches`); 343 + 344 // Invalidate caches after saving 345 + cache.invalidate("uploads"); 346 + cache.invalidatePattern("upload-details"); 347 + 348 return data; 349 } else { 350 + console.error("Failed to save results:", res.status, await res.text()); 351 return null; 352 } 353 } catch (error) { 354 + console.error( 355 + "Error saving results (will continue in background):", 356 + error, 357 + ); 358 return null; 359 } 360 }, ··· 364 clear: () => cache.clear(), 365 invalidate: (key: string) => cache.invalidate(key), 366 invalidatePattern: (pattern: string) => cache.invalidatePattern(pattern), 367 + }, 368 + };
+7 -7
src/lib/config.ts
··· 1 export const ENV = { 2 // Detect if we're in local mock mode 3 - IS_LOCAL_MOCK: import.meta.env.VITE_LOCAL_MOCK === 'true', 4 - 5 // API base URL 6 - API_BASE: import.meta.env.VITE_API_BASE || '/.netlify/functions', 7 - 8 // Feature flags 9 - ENABLE_OAUTH: import.meta.env.VITE_ENABLE_OAUTH !== 'false', 10 - ENABLE_DATABASE: import.meta.env.VITE_ENABLE_DATABASE !== 'false', 11 } as const; 12 13 export function isLocalMockMode(): boolean { ··· 16 17 export function getApiUrl(endpoint: string): string { 18 return `${ENV.API_BASE}/${endpoint}`; 19 - }
··· 1 export const ENV = { 2 // Detect if we're in local mock mode 3 + IS_LOCAL_MOCK: import.meta.env.VITE_LOCAL_MOCK === "true", 4 + 5 // API base URL 6 + API_BASE: import.meta.env.VITE_API_BASE || "/.netlify/functions", 7 + 8 // Feature flags 9 + ENABLE_OAUTH: import.meta.env.VITE_ENABLE_OAUTH !== "false", 10 + ENABLE_DATABASE: import.meta.env.VITE_ENABLE_DATABASE !== "false", 11 } as const; 12 13 export function isLocalMockMode(): boolean { ··· 16 17 export function getApiUrl(endpoint: string): string { 18 return `${ENV.API_BASE}/${endpoint}`; 19 + }
+118 -93
src/lib/fileExtractor.ts
··· 1 - import JSZip from 'jszip'; 2 - import { ParseRule, getRulesForPlatform, FileFormat } from './platformDefinitions'; 3 - import { parseContent } from './parserLogic'; 4 5 // Type for the final aggregated results 6 export interface ExtractionResults { 7 - allExtracted: Record<string, string[]>; 8 - uniqueUsernames: string[]; 9 } 10 11 export class DataExtractor { 12 - private file: File | ArrayBuffer | Blob; 13 14 - constructor(file: File | ArrayBuffer | Blob) { 15 - this.file = file; 16 - } 17 18 - public async processZipArchive(zip: JSZip, rules: ParseRule[]): Promise<ExtractionResults> { 19 - /** Core logic for extracting usernames from a successfully loaded ZIP archive. */ 20 - const allExtracted: Record<string, string[]> = {}; 21 - const uniqueUsernames: Set<string> = new Set(); 22 23 - for (let i = 0; i < rules.length; i++) { 24 - const rule = rules[i]; 25 - const ruleId = `Rule_${i + 1}_${rule.zipPath}`; 26 - console.log(`Processing ZIP file path ${rule.zipPath} (Format: ${rule.format})`); 27 - 28 - // 1. Get file object from ZIP 29 - const fileInZip = zip.file(rule.zipPath); 30 - if (!fileInZip) { 31 - console.warn(`WARNING: File not found in ZIP: '${rule.zipPath}'. Skipping rule.`); 32 - continue; 33 - } 34 35 - try { 36 - // 2. Read content asynchronously 37 - const content = await fileInZip.async("string"); 38 - 39 - // 3. Apply appropriate parsing logic 40 - const extracted = parseContent(content, rule); 41 - 42 - // 4. Store results 43 - allExtracted[ruleId] = extracted; 44 - extracted.forEach(name => uniqueUsernames.add(name)); 45 46 - } catch (e) { 47 - console.error(`ERROR reading file ${rule.zipPath} from ZIP:`, e); 48 - } 49 - } 50 51 - return { 52 - allExtracted, 53 - uniqueUsernames: Array.from(uniqueUsernames).sort() 54 - }; 55 } 56 } 57 58 /** ··· 61 * @param platform The platform name (e.g., 'instagram', 'tiktok'). 62 * @returns A promise that resolves to an array of unique usernames (string[]). 63 */ 64 - export async function parseDataFile(file: File | ArrayBuffer | Blob, platform: string): Promise<string[]> { 65 - const rules = getRulesForPlatform(platform); 66 - 67 - if (rules.length === 0) { 68 - console.error(`No parsing rules found for platform: ${platform}`); 69 - return []; 70 } 71 72 - // 1. --- ATTEMPT ZIP LOAD --- 73 - try { 74 - console.log("Attempting to load file as ZIP archive..."); 75 - const zip = await JSZip.loadAsync(file); 76 - 77 - const extractor = new DataExtractor(file); 78 - const results = await extractor.processZipArchive(zip, rules); 79 - 80 - console.log(`Successfully extracted ${results.uniqueUsernames.length} usernames from ZIP archive.`); 81 - return results.uniqueUsernames; 82 83 - } catch (e) { 84 - // 2. --- ZIP LOAD FAILED, ATTEMPT SINGLE FILE --- 85 - console.warn("ZIP load failed. Attempting to parse file as a single data file..."); 86 - 87 - // We need a File object to get the name and content easily 88 - if (!(file instanceof File) && !(file instanceof Blob)) { 89 - console.error("Input failed ZIP check and lacks a name/content structure for single file parsing (must be File or Blob)."); 90 - return []; 91 - } 92 93 - const singleFile = file as File; 94 - 95 - // Find the rule that matches the uploaded file name 96 - // We check if the uploaded filename ends with the final part of a rule's zipPath (e.g., "following.html") 97 - const matchingRule = rules.find(rule => 98 - singleFile.name.toLowerCase().endsWith((rule.zipPath.split('/').pop() || '').toLowerCase()) 99 - ); 100 101 - if (!matchingRule) { 102 - console.error(`Could not match single file '${singleFile.name}' to any rule for platform ${platform}. Check rules in platformDefinitions.ts.`); 103 - return []; 104 - } 105 106 - console.log(`Matched single file '${singleFile.name}' to rule: ${matchingRule.zipPath}`); 107 108 - // 3. Process as single file content 109 - try { 110 - const content = await singleFile.text(); 111 - const extracted = parseContent(content, matchingRule); 112 113 - const uniqueUsernames = Array.from(new Set(extracted)).sort(); 114 - console.log(`Successfully extracted ${uniqueUsernames.length} unique usernames from single file.`); 115 - 116 - return uniqueUsernames; 117 - 118 - } catch (contentError) { 119 - console.error("Error reading content of single file:", contentError); 120 - return []; 121 - } 122 } 123 - }
··· 1 + import JSZip from "jszip"; 2 + import { 3 + ParseRule, 4 + getRulesForPlatform, 5 + FileFormat, 6 + } from "./platformDefinitions"; 7 + import { parseContent } from "./parserLogic"; 8 9 // Type for the final aggregated results 10 export interface ExtractionResults { 11 + allExtracted: Record<string, string[]>; 12 + uniqueUsernames: string[]; 13 } 14 15 export class DataExtractor { 16 + private file: File | ArrayBuffer | Blob; 17 18 + constructor(file: File | ArrayBuffer | Blob) { 19 + this.file = file; 20 + } 21 22 + public async processZipArchive( 23 + zip: JSZip, 24 + rules: ParseRule[], 25 + ): Promise<ExtractionResults> { 26 + /** Core logic for extracting usernames from a successfully loaded ZIP archive. */ 27 + const allExtracted: Record<string, string[]> = {}; 28 + const uniqueUsernames: Set<string> = new Set(); 29 30 + for (let i = 0; i < rules.length; i++) { 31 + const rule = rules[i]; 32 + const ruleId = `Rule_${i + 1}_${rule.zipPath}`; 33 + console.log( 34 + `Processing ZIP file path ${rule.zipPath} (Format: ${rule.format})`, 35 + ); 36 37 + // 1. Get file object from ZIP 38 + const fileInZip = zip.file(rule.zipPath); 39 + if (!fileInZip) { 40 + console.warn( 41 + `WARNING: File not found in ZIP: '${rule.zipPath}'. Skipping rule.`, 42 + ); 43 + continue; 44 + } 45 + 46 + try { 47 + // 2. Read content asynchronously 48 + const content = await fileInZip.async("string"); 49 50 + // 3. Apply appropriate parsing logic 51 + const extracted = parseContent(content, rule); 52 53 + // 4. Store results 54 + allExtracted[ruleId] = extracted; 55 + extracted.forEach((name) => uniqueUsernames.add(name)); 56 + } catch (e) { 57 + console.error(`ERROR reading file ${rule.zipPath} from ZIP:`, e); 58 + } 59 } 60 + 61 + return { 62 + allExtracted, 63 + uniqueUsernames: Array.from(uniqueUsernames).sort(), 64 + }; 65 + } 66 } 67 68 /** ··· 71 * @param platform The platform name (e.g., 'instagram', 'tiktok'). 72 * @returns A promise that resolves to an array of unique usernames (string[]). 73 */ 74 + export async function parseDataFile( 75 + file: File | ArrayBuffer | Blob, 76 + platform: string, 77 + ): Promise<string[]> { 78 + const rules = getRulesForPlatform(platform); 79 + 80 + if (rules.length === 0) { 81 + console.error(`No parsing rules found for platform: ${platform}`); 82 + return []; 83 + } 84 + 85 + // 1. --- ATTEMPT ZIP LOAD --- 86 + try { 87 + console.log("Attempting to load file as ZIP archive..."); 88 + const zip = await JSZip.loadAsync(file); 89 + 90 + const extractor = new DataExtractor(file); 91 + const results = await extractor.processZipArchive(zip, rules); 92 + 93 + console.log( 94 + `Successfully extracted ${results.uniqueUsernames.length} usernames from ZIP archive.`, 95 + ); 96 + return results.uniqueUsernames; 97 + } catch (e) { 98 + // 2. --- ZIP LOAD FAILED, ATTEMPT SINGLE FILE --- 99 + console.warn( 100 + "ZIP load failed. Attempting to parse file as a single data file...", 101 + ); 102 + 103 + // We need a File object to get the name and content easily 104 + if (!(file instanceof File) && !(file instanceof Blob)) { 105 + console.error( 106 + "Input failed ZIP check and lacks a name/content structure for single file parsing (must be File or Blob).", 107 + ); 108 + return []; 109 } 110 111 + const singleFile = file as File; 112 113 + // Find the rule that matches the uploaded file name 114 + // We check if the uploaded filename ends with the final part of a rule's zipPath (e.g., "following.html") 115 + const matchingRule = rules.find((rule) => 116 + singleFile.name 117 + .toLowerCase() 118 + .endsWith((rule.zipPath.split("/").pop() || "").toLowerCase()), 119 + ); 120 121 + if (!matchingRule) { 122 + console.error( 123 + `Could not match single file '${singleFile.name}' to any rule for platform ${platform}. Check rules in platformDefinitions.ts.`, 124 + ); 125 + return []; 126 + } 127 128 + console.log( 129 + `Matched single file '${singleFile.name}' to rule: ${matchingRule.zipPath}`, 130 + ); 131 132 + // 3. Process as single file content 133 + try { 134 + const content = await singleFile.text(); 135 + const extracted = parseContent(content, matchingRule); 136 137 + const uniqueUsernames = Array.from(new Set(extracted)).sort(); 138 + console.log( 139 + `Successfully extracted ${uniqueUsernames.length} unique usernames from single file.`, 140 + ); 141 142 + return uniqueUsernames; 143 + } catch (contentError) { 144 + console.error("Error reading content of single file:", contentError); 145 + return []; 146 } 147 + } 148 + }
+92 -73
src/lib/parserLogic.ts
··· 1 - import { ParseRule, FileFormat } from './platformDefinitions'; 2 3 /** 4 * Parses content using a regular expression. ··· 6 * @param regexPattern The regex string defining the capture group for the username. 7 * @returns An array of extracted usernames. 8 */ 9 - export function parseTextOrHtml(content: string, regexPattern: string): string[] { 10 - try { 11 - // 'g' for global matching, 's' for multiline (DOTALL equivalent) 12 - const pattern = new RegExp(regexPattern, 'gs'); 13 - 14 - // matchAll returns an iterator of matches; we spread it into an array. 15 - const matches = [...content.matchAll(pattern)]; 16 - 17 - // We map the results to the first captured group (match[1]), filtering out empty results. 18 - return matches.map(match => match[1].trim()).filter(name => !!name); 19 - 20 - } catch (e) { 21 - console.error(`ERROR: Invalid regex pattern '${regexPattern}':`, e); 22 - return []; 23 - } 24 } 25 26 /** ··· 31 * @returns An array of extracted usernames. 32 */ 33 export function parseJson(content: string, pathKeys: string[]): string[] { 34 - try { 35 - const data = JSON.parse(content); 36 - const usernames: string[] = []; 37 38 - if (pathKeys.length < 2) { 39 - console.error("JSON rule must have at least two path keys (list key and target key)."); 40 - return []; 41 - } 42 43 - // Determine the navigation path 44 - let currentData: any = data; 45 - const listContainerPath = pathKeys.slice(0, -2); 46 - const listKey = pathKeys[pathKeys.length - 2]; 47 - const targetKey = pathKeys[pathKeys.length - 1]; 48 49 - // 1. Traverse down to the object containing the target array 50 - for (const key of listContainerPath) { 51 - if (typeof currentData === 'object' && currentData !== null && key in currentData) { 52 - currentData = currentData[key]; 53 - } else { 54 - console.error(`ERROR: Could not traverse JSON path up to key: ${key}. Path: ${listContainerPath.join(' -> ')}`); 55 - return []; 56 - } 57 - } 58 59 - // 2. Check if the penultimate key holds the array 60 - if (typeof currentData === 'object' && currentData !== null && listKey in currentData) { 61 - const userList = currentData[listKey]; 62 63 - if (Array.isArray(userList)) { 64 - // 3. Iterate over the array and extract the final target key 65 - for (const item of userList) { 66 - if (typeof item === 'object' && item !== null && targetKey in item) { 67 - // Found the username 68 - usernames.push(String(item[targetKey])); 69 - } 70 - } 71 - } else { 72 - console.error(`ERROR: Expected an array at key '${listKey}' but found a different type.`); 73 - } 74 - } else { 75 - console.error(`ERROR: List key '${listKey}' not found at its expected position.`); 76 } 77 - 78 - return usernames; 79 80 - } catch (e) { 81 - if (e instanceof SyntaxError) { 82 - console.error(`ERROR: Could not decode JSON content:`, e); 83 - } else { 84 - console.error(`An unexpected error occurred during JSON parsing:`, e); 85 - } 86 - return []; 87 } 88 } 89 90 /** ··· 94 * @returns An array of extracted usernames. 95 */ 96 export function parseContent(content: string, rule: ParseRule): string[] { 97 - if (rule.format === 'HTML' || rule.format === 'TEXT') { 98 - if (typeof rule.rule === 'string') { 99 - return parseTextOrHtml(content, rule.rule); 100 - } 101 - } else if (rule.format === 'JSON') { 102 - if (Array.isArray(rule.rule)) { 103 - return parseJson(content, rule.rule); 104 - } 105 } 106 - console.error(`ERROR: Unsupported format or invalid rule type for rule with path: ${rule.zipPath}`); 107 - return []; 108 - }
··· 1 + import { ParseRule, FileFormat } from "./platformDefinitions"; 2 3 /** 4 * Parses content using a regular expression. ··· 6 * @param regexPattern The regex string defining the capture group for the username. 7 * @returns An array of extracted usernames. 8 */ 9 + export function parseTextOrHtml( 10 + content: string, 11 + regexPattern: string, 12 + ): string[] { 13 + try { 14 + // 'g' for global matching, 's' for multiline (DOTALL equivalent) 15 + const pattern = new RegExp(regexPattern, "gs"); 16 + 17 + // matchAll returns an iterator of matches; we spread it into an array. 18 + const matches = [...content.matchAll(pattern)]; 19 + 20 + // We map the results to the first captured group (match[1]), filtering out empty results. 21 + return matches.map((match) => match[1].trim()).filter((name) => !!name); 22 + } catch (e) { 23 + console.error(`ERROR: Invalid regex pattern '${regexPattern}':`, e); 24 + return []; 25 + } 26 } 27 28 /** ··· 33 * @returns An array of extracted usernames. 34 */ 35 export function parseJson(content: string, pathKeys: string[]): string[] { 36 + try { 37 + const data = JSON.parse(content); 38 + const usernames: string[] = []; 39 40 + if (pathKeys.length < 2) { 41 + console.error( 42 + "JSON rule must have at least two path keys (list key and target key).", 43 + ); 44 + return []; 45 + } 46 47 + // Determine the navigation path 48 + let currentData: any = data; 49 + const listContainerPath = pathKeys.slice(0, -2); 50 + const listKey = pathKeys[pathKeys.length - 2]; 51 + const targetKey = pathKeys[pathKeys.length - 1]; 52 53 + // 1. Traverse down to the object containing the target array 54 + for (const key of listContainerPath) { 55 + if ( 56 + typeof currentData === "object" && 57 + currentData !== null && 58 + key in currentData 59 + ) { 60 + currentData = currentData[key]; 61 + } else { 62 + console.error( 63 + `ERROR: Could not traverse JSON path up to key: ${key}. Path: ${listContainerPath.join(" -> ")}`, 64 + ); 65 + return []; 66 + } 67 + } 68 69 + // 2. Check if the penultimate key holds the array 70 + if ( 71 + typeof currentData === "object" && 72 + currentData !== null && 73 + listKey in currentData 74 + ) { 75 + const userList = currentData[listKey]; 76 77 + if (Array.isArray(userList)) { 78 + // 3. Iterate over the array and extract the final target key 79 + for (const item of userList) { 80 + if (typeof item === "object" && item !== null && targetKey in item) { 81 + // Found the username 82 + usernames.push(String(item[targetKey])); 83 + } 84 } 85 + } else { 86 + console.error( 87 + `ERROR: Expected an array at key '${listKey}' but found a different type.`, 88 + ); 89 + } 90 + } else { 91 + console.error( 92 + `ERROR: List key '${listKey}' not found at its expected position.`, 93 + ); 94 + } 95 96 + return usernames; 97 + } catch (e) { 98 + if (e instanceof SyntaxError) { 99 + console.error(`ERROR: Could not decode JSON content:`, e); 100 + } else { 101 + console.error(`An unexpected error occurred during JSON parsing:`, e); 102 } 103 + return []; 104 + } 105 } 106 107 /** ··· 111 * @returns An array of extracted usernames. 112 */ 113 export function parseContent(content: string, rule: ParseRule): string[] { 114 + if (rule.format === "HTML" || rule.format === "TEXT") { 115 + if (typeof rule.rule === "string") { 116 + return parseTextOrHtml(content, rule.rule); 117 + } 118 + } else if (rule.format === "JSON") { 119 + if (Array.isArray(rule.rule)) { 120 + return parseJson(content, rule.rule); 121 } 122 + } 123 + console.error( 124 + `ERROR: Unsupported format or invalid rule type for rule with path: ${rule.zipPath}`, 125 + ); 126 + return []; 127 + }
+36 -37
src/lib/platformDefinitions.ts
··· 1 // Use string literals for type safety on formats 2 - export type FileFormat = 'HTML' | 'TEXT' | 'JSON'; 3 4 // Define the structure for a single parsing rule 5 export interface ParseRule { 6 - zipPath: string; // File path *inside* the ZIP archive 7 - format: FileFormat; // Expected format of the file, e.g. 'HTML', 'TEXT', 'JSON' 8 - rule: string | string[]; // specific extraction rule (regex pattern string or JSON key path array) 9 } 10 11 /* ··· 14 */ 15 16 export const PLATFORM_RULES: Record<string, ParseRule[]> = { 17 - 18 - "instagram": [ 19 - { 20 - zipPath: "connections/followers_and_following/following.html", 21 - format: "HTML", 22 - // Regex captures the username group 'beautyscicomm' from the URL: 23 - // https://www.instagram.com/_u/beautyscicomm 24 - // Note: The 'g' and 's' flags are handled in the extractor method. 25 - rule: '<a target="_blank" href="https://www.instagram.com/_u/([^"]+)"' 26 - }, 27 - { 28 - zipPath: "connections/followers_and_following/following.json", 29 - format: "JSON", 30 - rule: ["relationships_following", "title"] 31 - } 32 - ], 33 34 - "tiktok": [ 35 - { 36 - zipPath: "TikTok/Profile and Settings/Following.txt", 37 - format: "TEXT", 38 - // Regex captures the text after "Username: " on the same line 39 - rule: "Username:\s*([^\r\n]+)" 40 - }, 41 - { 42 - zipPath: "user_data_tiktok.json", 43 - format: "JSON", 44 - // JSON key path to traverse: ['Your Activity'] -> ['Following'] -> ['Following'] -> 'UserName' 45 - rule: ["Your Activity", "Following", "Following", "UserName"] 46 - } 47 - ], 48 }; 49 50 export function getRulesForPlatform(platformName: string): ParseRule[] { 51 - // Retrieves the list of parsing rules for a given platform. 52 - return PLATFORM_RULES[platformName.toLowerCase()] || []; 53 - }
··· 1 // Use string literals for type safety on formats 2 + export type FileFormat = "HTML" | "TEXT" | "JSON"; 3 4 // Define the structure for a single parsing rule 5 export interface ParseRule { 6 + zipPath: string; // File path *inside* the ZIP archive 7 + format: FileFormat; // Expected format of the file, e.g. 'HTML', 'TEXT', 'JSON' 8 + rule: string | string[]; // specific extraction rule (regex pattern string or JSON key path array) 9 } 10 11 /* ··· 14 */ 15 16 export const PLATFORM_RULES: Record<string, ParseRule[]> = { 17 + instagram: [ 18 + { 19 + zipPath: "connections/followers_and_following/following.html", 20 + format: "HTML", 21 + // Regex captures the username group 'beautyscicomm' from the URL: 22 + // https://www.instagram.com/_u/beautyscicomm 23 + // Note: The 'g' and 's' flags are handled in the extractor method. 24 + rule: '<a target="_blank" href="https://www.instagram.com/_u/([^"]+)"', 25 + }, 26 + { 27 + zipPath: "connections/followers_and_following/following.json", 28 + format: "JSON", 29 + rule: ["relationships_following", "title"], 30 + }, 31 + ], 32 33 + tiktok: [ 34 + { 35 + zipPath: "TikTok/Profile and Settings/Following.txt", 36 + format: "TEXT", 37 + // Regex captures the text after "Username: " on the same line 38 + rule: "Username:\s*([^\r\n]+)", 39 + }, 40 + { 41 + zipPath: "user_data_tiktok.json", 42 + format: "JSON", 43 + // JSON key path to traverse: ['Your Activity'] -> ['Following'] -> ['Following'] -> 'UserName' 44 + rule: ["Your Activity", "Following", "Following", "UserName"], 45 + }, 46 + ], 47 }; 48 49 export function getRulesForPlatform(platformName: string): ParseRule[] { 50 + // Retrieves the list of parsing rules for a given platform. 51 + return PLATFORM_RULES[platformName.toLowerCase()] || []; 52 + }
+6 -6
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 "./index.css"; 5 6 + ReactDOM.createRoot(document.getElementById("root")!).render( 7 <React.StrictMode> 8 <App /> 9 </React.StrictMode>, 10 + );
+8 -2
src/types/index.ts
··· 44 } 45 46 // App State 47 - export type AppStep = 'checking' | 'login' | 'home' | 'upload' | 'loading' | 'results'; 48 49 // API Response Types 50 export interface BatchSearchResult { ··· 74 totalUsers: number; 75 matchedUsers: number; 76 unmatchedUsers: number; 77 - }
··· 44 } 45 46 // App State 47 + export type AppStep = 48 + | "checking" 49 + | "login" 50 + | "home" 51 + | "upload" 52 + | "loading" 53 + | "results"; 54 55 // API Response Types 56 export interface BatchSearchResult { ··· 80 totalUsers: number; 81 matchedUsers: number; 82 unmatchedUsers: number; 83 + }
+1 -1
src/vite-env.d.ts
··· 7 8 interface ImportMeta { 9 readonly env: ImportMetaEnv; 10 - }
··· 7 8 interface ImportMeta { 9 readonly env: ImportMetaEnv; 10 + }