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