+75
-72
src/App.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}