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