+1
netlify/functions/get-profile.ts
+1
netlify/functions/get-profile.ts
+524
-570
src/App.tsx
+524
-570
src/App.tsx
···
1
1
import { useState, useEffect, useRef } from "react";
2
-
import { Upload, User, Check, Search, ArrowRight, Users, FileText, ChevronRight, LogOut, Home } from "lucide-react";
2
+
import { Upload, Check, Search, ArrowRight, ChevronRight, LogOut, Home, Heart, Clock, Trash2, UserPlus, ChevronDown, Twitter, Instagram, Video, Hash, Gamepad2, MessageCircle, Music, Menu, User } from "lucide-react";
3
3
import JSZip from "jszip";
4
-
import {
5
-
CompositeDidDocumentResolver,
6
-
CompositeHandleResolver,
7
-
PlcDidDocumentResolver,
8
-
AtprotoWebDidDocumentResolver,
9
-
DohJsonHandleResolver,
10
-
WellKnownHandleResolver
11
-
} from "@atcute/identity-resolver";
12
4
13
5
interface atprotoSession {
14
6
did: string;
15
7
handle: string;
16
8
displayName?: string;
17
9
avatar?: string;
10
+
description?: string;
18
11
}
19
12
20
13
interface TikTokUser {
···
30
23
selectedMatches?: Set<string>; // Track selected match DIDs
31
24
}
32
25
33
-
// Match Carousel Component
34
-
function MatchCarousel({
35
-
matches,
36
-
selectedDids,
37
-
onToggleSelection,
38
-
cardRef
39
-
}: {
40
-
matches: any[];
41
-
selectedDids: Set<string>;
42
-
onToggleSelection: (did: string) => void;
43
-
cardRef?: React.RefObject<HTMLDivElement | null>;
44
-
}) {
45
-
const [currentIndex, setCurrentIndex] = useState(0);
46
-
const [touchStart, setTouchStart] = useState<number | null>(null);
47
-
const [touchEnd, setTouchEnd] = useState<number | null>(null);
48
-
49
-
const currentMatch = matches[currentIndex];
50
-
const hasMore = matches.length > 1;
51
-
const hasPrev = currentIndex > 0;
52
-
const hasNext = currentIndex < matches.length - 1;
53
-
54
-
const minSwipeDistance = 50;
55
-
56
-
const nextMatch = () => {
57
-
if (hasNext) {
58
-
setCurrentIndex(currentIndex + 1);
26
+
const PLATFORMS = {
27
+
twitter: {
28
+
name: 'Twitter/X',
29
+
icon: Twitter,
30
+
color: 'from-blue-400 to-blue-600',
31
+
accentBg: 'bg-blue-500',
32
+
fileHint: 'following.js or account data ZIP',
33
+
},
34
+
instagram: {
35
+
name: 'Instagram',
36
+
icon: Instagram,
37
+
color: 'from-pink-500 via-purple-500 to-orange-500',
38
+
accentBg: 'bg-pink-500',
39
+
fileHint: 'connections.json or data ZIP',
40
+
},
41
+
tiktok: {
42
+
name: 'TikTok',
43
+
icon: Video,
44
+
color: 'from-black via-gray-800 to-cyan-400',
45
+
accentBg: 'bg-black',
46
+
fileHint: 'Following.txt or data ZIP',
47
+
},
48
+
tumblr: {
49
+
name: 'Tumblr',
50
+
icon: Hash,
51
+
color: 'from-indigo-600 to-blue-800',
52
+
accentBg: 'bg-indigo-600',
53
+
fileHint: 'following.csv or data export',
54
+
},
55
+
twitch: {
56
+
name: 'Twitch',
57
+
icon: Gamepad2,
58
+
color: 'from-purple-600 to-purple-800',
59
+
accentBg: 'bg-purple-600',
60
+
fileHint: 'following.json or data export',
61
+
},
62
+
youtube: {
63
+
name: 'YouTube',
64
+
icon: Video,
65
+
color: 'from-red-600 to-red-700',
66
+
accentBg: 'bg-red-600',
67
+
fileHint: 'subscriptions.csv or Takeout ZIP',
68
+
},
69
+
};
70
+
71
+
function AppHeader({ session, onLogout, onNavigate, currentStep }: { session: atprotoSession | null; onLogout: () => void; onNavigate: (step: 'home' | 'login') => void; currentStep: string }) {
72
+
const [showMenu, setShowMenu] = useState(false);
73
+
const menuRef = useRef<HTMLDivElement>(null);
74
+
75
+
useEffect(() => {
76
+
function handleClickOutside(event: MouseEvent) {
77
+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
78
+
setShowMenu(false);
79
+
}
59
80
}
60
-
};
61
-
62
-
const prevMatch = () => {
63
-
if (hasPrev) {
64
-
setCurrentIndex(currentIndex - 1);
65
-
}
66
-
};
67
-
68
-
const handleKeyDown = (e: React.KeyboardEvent) => {
69
-
if (e.key === 'ArrowLeft') {
70
-
e.preventDefault();
71
-
prevMatch();
72
-
} else if (e.key === 'ArrowRight') {
73
-
e.preventDefault();
74
-
nextMatch();
75
-
} else if (e.key === ' ' || e.key === 'Enter') {
76
-
e.preventDefault();
77
-
onToggleSelection(currentMatch.did);
78
-
}
79
-
};
80
-
81
-
const onTouchStart = (e: React.TouchEvent) => {
82
-
setTouchEnd(null);
83
-
setTouchStart(e.targetTouches[0].clientX);
84
-
};
85
-
86
-
const onTouchMove = (e: React.TouchEvent) => {
87
-
setTouchEnd(e.targetTouches[0].clientX);
88
-
};
89
-
90
-
const onTouchEnd = () => {
91
-
if (!touchStart || !touchEnd) return;
92
-
93
-
const distance = touchStart - touchEnd;
94
-
const isLeftSwipe = distance > minSwipeDistance;
95
-
const isRightSwipe = distance < -minSwipeDistance;
96
-
97
-
if (isLeftSwipe && hasNext) {
98
-
nextMatch();
99
-
} else if (isRightSwipe && hasPrev) {
100
-
prevMatch();
101
-
}
102
-
};
81
+
document.addEventListener('mousedown', handleClickOutside);
82
+
return () => document.removeEventListener('mousedown', handleClickOutside);
83
+
}, []);
103
84
104
-
const matchLabel = `${currentMatch.displayName || currentMatch.handle}, ${currentMatch.matchScore} percent match${currentMatch.followed ? ', already followed' : ''}${hasMore ? `, match ${currentIndex + 1} of ${matches.length}` : ''}`;
105
-
106
85
return (
107
-
<div
108
-
className="relative"
109
-
onTouchStart={onTouchStart}
110
-
onTouchMove={onTouchMove}
111
-
onTouchEnd={onTouchEnd}
112
-
>
113
-
<div
114
-
ref={(el) => {
115
-
if (cardRef) {
116
-
cardRef.current = el;
117
-
}
118
-
}}
119
-
className={`flex items-center space-x-3 p-3 rounded-lg border transition-all focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 ${
120
-
selectedDids.has(currentMatch.did)
121
-
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
122
-
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600'
123
-
} ${currentMatch.followed ? 'opacity-60' : ''}`}
124
-
onKeyDown={handleKeyDown}
125
-
onFocus={(e) => {
126
-
if (e.target === e.currentTarget) {
127
-
e.currentTarget.scrollIntoView({
128
-
behavior: 'smooth',
129
-
block: 'center',
130
-
inline: 'nearest'
131
-
});
132
-
}
133
-
}}
134
-
tabIndex={0}
135
-
role="button"
136
-
aria-label={matchLabel}
137
-
aria-pressed={selectedDids.has(currentMatch.did)}
138
-
aria-disabled={currentMatch.followed}
139
-
>
140
-
<div
141
-
className="flex items-center justify-center min-w-[44px] min-h-[44px] cursor-pointer flex-shrink-0"
142
-
onClick={() => !currentMatch.followed && onToggleSelection(currentMatch.did)}
143
-
aria-hidden="true"
144
-
>
145
-
<div className={`w-5 h-5 border-2 rounded flex items-center justify-center transition-colors ${
146
-
selectedDids.has(currentMatch.did)
147
-
? 'bg-blue-600 border-blue-600'
148
-
: 'bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-500'
149
-
} ${currentMatch.followed ? 'opacity-50 cursor-not-allowed' : ''}`}>
150
-
{selectedDids.has(currentMatch.did) && (
151
-
<Check className="w-3 h-3 text-white" />
152
-
)}
153
-
</div>
154
-
</div>
155
-
156
-
{currentMatch.avatar ? (
157
-
<img
158
-
src={currentMatch.avatar}
159
-
alt=""
160
-
className="w-12 h-12 rounded-full object-cover flex-shrink-0"
161
-
/>
162
-
) : (
163
-
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center flex-shrink-0" aria-hidden="true">
164
-
<span className="text-white font-bold text-sm">
165
-
{currentMatch.handle.charAt(0).toUpperCase()}
166
-
</span>
167
-
</div>
168
-
)}
169
-
170
-
<div className="flex-1 min-w-0" aria-hidden="true">
171
-
{currentMatch.displayName && (
172
-
<div className="font-medium text-gray-900 dark:text-gray-100 truncate">
173
-
{currentMatch.displayName}
86
+
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
87
+
<div className="max-w-6xl mx-auto px-4 py-3">
88
+
<div className="flex items-center justify-between">
89
+
<button onClick={() => onNavigate(session ? 'home' : 'login')} className="flex items-center space-x-3 hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg px-2 py-1">
90
+
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
91
+
<Heart className="w-5 h-5 text-white" />
92
+
</div>
93
+
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">ATlast</h1>
94
+
</button>
95
+
96
+
{session && (
97
+
<div className="relative" ref={menuRef}>
98
+
<button onClick={() => setShowMenu(!showMenu)} className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500">
99
+
{session.avatar ? (
100
+
<img src={session.avatar} alt="" className="w-8 h-8 rounded-full object-cover" />
101
+
) : (
102
+
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
103
+
<span className="text-white font-bold text-sm">{session.handle.charAt(0).toUpperCase()}</span>
104
+
</div>
105
+
)}
106
+
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 hidden sm:inline">@{session.handle}</span>
107
+
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${showMenu ? 'rotate-180' : ''}`} />
108
+
</button>
109
+
110
+
{showMenu && (
111
+
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-2 z-50">
112
+
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
113
+
<div className="font-medium text-gray-900 dark:text-gray-100">{session.displayName || session.handle}</div>
114
+
<div className="text-sm text-gray-500 dark:text-gray-400">@{session.handle}</div>
115
+
</div>
116
+
<button onClick={() => { setShowMenu(false); onNavigate('home'); }} className="w-full flex items-center space-x-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-left">
117
+
<Home className="w-4 h-4 text-gray-500" />
118
+
<span className="text-gray-900 dark:text-gray-100">Dashboard</span>
119
+
</button>
120
+
<button onClick={() => { setShowMenu(false); onNavigate('login'); }} className="w-full flex items-center space-x-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-left">
121
+
<Heart className="w-4 h-4 text-gray-500" />
122
+
<span className="text-gray-900 dark:text-gray-100">About</span>
123
+
</button>
124
+
<div className="border-t border-gray-200 dark:border-gray-700 my-2"></div>
125
+
<button onClick={() => { setShowMenu(false); onLogout(); }} className="w-full flex items-center space-x-3 px-4 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-left text-red-600 dark:text-red-400">
126
+
<LogOut className="w-4 h-4" />
127
+
<span>Log out</span>
128
+
</button>
129
+
</div>
130
+
)}
174
131
</div>
175
132
)}
176
-
<div className="flex items-center space-x-2">
177
-
<div className="text-sm text-gray-600 dark:text-gray-300 truncate">
178
-
@{currentMatch.handle}
179
-
</div>
180
-
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 px-2 py-0.5 rounded flex-shrink-0">
181
-
{currentMatch.matchScore}%
182
-
</span>
183
-
</div>
184
133
</div>
185
-
186
-
{currentMatch.followed && (
187
-
<div className="flex-shrink-0" aria-hidden="true">
188
-
<div className="flex items-center space-x-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 px-2 py-1 rounded-full text-xs">
189
-
<Check className="w-3 h-3" />
190
-
<span>Followed</span>
191
-
</div>
192
-
</div>
193
-
)}
194
-
195
-
{hasMore && (
196
-
<div className="flex items-center space-x-1 flex-shrink-0" aria-hidden="true">
197
-
{hasPrev && (
198
-
<div className="p-2 text-gray-400 dark:text-gray-500">
199
-
<ChevronRight className="w-5 h-5 rotate-180" />
200
-
</div>
201
-
)}
202
-
{hasNext && (
203
-
<div className="p-2 text-gray-400 dark:text-gray-500">
204
-
<ChevronRight className="w-5 h-5" />
205
-
</div>
206
-
)}
207
-
</div>
208
-
)}
209
134
</div>
210
-
211
-
{hasMore && (
212
-
<div className="flex items-center justify-center space-x-2 mt-2" aria-hidden="true">
213
-
{matches.map((_, idx) => (
214
-
<div
215
-
key={idx}
216
-
className={`h-1.5 rounded-full transition-all ${
217
-
idx === currentIndex
218
-
? 'w-6 bg-blue-500'
219
-
: 'w-1.5 bg-gray-300'
220
-
}`}
221
-
/>
222
-
))}
223
-
</div>
224
-
)}
225
135
</div>
226
136
);
227
137
}
228
138
229
139
export default function App() {
230
140
const [handle, setHandle] = useState("");
231
-
const [appPassword, setAppPassword] = useState("");
232
141
const [session, setSession] = useState<atprotoSession | null>(null);
233
-
const [useAppPassword, setUseAppPassword] = useState(false);
234
142
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
235
143
const [isSearchingAll, setIsSearchingAll] = useState(false);
236
144
const [currentStep, setCurrentStep] = useState<'checking' | 'login' | 'home' | 'upload' | 'loading' | 'results'>('checking');
237
145
const [searchProgress, setSearchProgress] = useState({ searched: 0, found: 0, total: 0 });
238
146
const [isFollowing, setIsFollowing] = useState(false);
239
147
const [statusMessage, setStatusMessage] = useState("");
240
-
const resultCardRefs = useRef<(HTMLDivElement | null)[]>([]);
241
-
242
-
const didDocumentResolver = new CompositeDidDocumentResolver({
243
-
methods: {
244
-
plc: new PlcDidDocumentResolver({ apiUrl: "https://plc.directory" }),
245
-
web: new AtprotoWebDidDocumentResolver(),
246
-
},
247
-
});
248
-
249
-
const handleResolver = new CompositeHandleResolver({
250
-
strategy: "dns-first",
251
-
methods: {
252
-
dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }),
253
-
http: new WellKnownHandleResolver(),
254
-
},
255
-
});
148
+
const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set());
256
149
257
150
// Check for existing session on mount
258
151
useEffect(() => {
···
289
182
290
183
if (res.ok) {
291
184
const data = await res.json();
292
-
setSession({
293
-
did: data.did,
294
-
handle: data.handle,
295
-
displayName: data.displayName,
296
-
avatar: data.avatar,
297
-
});
185
+
await fetchProfile();
298
186
setCurrentStep('home');
299
187
setStatusMessage(`Welcome back, ${data.handle}!`);
300
188
} else {
···
322
210
handle: data.handle,
323
211
displayName: data.displayName,
324
212
avatar: data.avatar,
213
+
description: data.description,
325
214
});
326
215
setStatusMessage(`Successfully logged in as ${data.handle}`);
327
216
} catch (err) {
···
356
245
}
357
246
358
247
// Start OAuth login
359
-
const loginWithOAuth = async () => {
248
+
const loginWithOAuth = async (e: React.FormEvent) => {
249
+
e.preventDefault();
360
250
try {
361
251
if (!handle) {
362
252
const errorMsg = "Please enter your handle";
···
393
283
}
394
284
};
395
285
396
-
// App Password Login (Fallback method)
397
-
async function loginWithAppPassword() {
398
-
try {
399
-
if (!handle || !appPassword) {
400
-
alert("Enter handle and app password");
401
-
return;
402
-
}
403
-
404
-
// Step 1: Resolve handle → DID
405
-
const did = await handleResolver.resolve(handle as `${string}.${string}`);
406
-
if (!did) {
407
-
alert("Failed to resolve handle to DID");
408
-
return;
409
-
}
410
-
411
-
// Step 2: Resolve DID → DID Document
412
-
const didDoc = await didDocumentResolver.resolve(did);
413
-
if (!didDoc?.service?.[0]?.serviceEndpoint) {
414
-
alert("Could not determine PDS endpoint from DID Document");
415
-
return;
416
-
}
417
-
418
-
// Step 3: Extract PDS endpoint
419
-
const pdsEndpoint = didDoc.service[0].serviceEndpoint;
420
-
421
-
// Step 4: Authenticate via App Password
422
-
const sessionRes = await fetch(`${pdsEndpoint}/xrpc/com.atproto.server.createSession`, {
423
-
method: "POST",
424
-
headers: { "Content-Type": "application/json" },
425
-
body: JSON.stringify({ identifier: handle, password: appPassword }),
426
-
});
427
-
428
-
if (!sessionRes.ok) {
429
-
const errText = await sessionRes.text();
430
-
console.error("Login failed:", errText);
431
-
alert("Login failed, check handle and app password");
432
-
return;
433
-
}
434
-
435
-
const sessionData = await sessionRes.json();
436
-
437
-
// Step 5: Store session + PDS endpoint for future API calls
438
-
setSession({
439
-
...sessionData,
440
-
serviceEndpoint: pdsEndpoint,
441
-
});
442
-
443
-
setCurrentStep('home');
444
-
445
-
console.log("Logged in successfully!", sessionData, pdsEndpoint);
446
-
} catch (err) {
447
-
console.error("Login error:", err);
448
-
alert("Error during login. See console for details.");
449
-
}
450
-
}
451
-
452
286
async function parseJsonFile(jsonText: string): Promise<TikTokUser[]> {
453
287
const users: TikTokUser[] = [];
454
288
const jsonData = JSON.parse(jsonText);
···
531
365
532
366
// Search all users
533
367
async function searchAllUsers(resultsToSearch?: SearchResult[]) {
534
-
console.log('sau Session value:', session);
535
368
const targetResults = resultsToSearch || searchResults;
536
369
if (!session || targetResults.length === 0) return;
537
370
···
579
412
const data = await res.json();
580
413
581
414
// Process batch results
582
-
data.results.forEach((result: any, batchIndex: number) => {
583
-
const globalIndex = i + batchIndex;
415
+
data.results.forEach((result: any) => {
584
416
totalSearched++;
585
417
if (result.actors.length > 0) {
586
418
totalFound++;
···
658
490
// Direct JSON upload
659
491
if (file.name.endsWith(".json")) {
660
492
users = await parseJsonFile(await file.text());
661
-
console.log(`Loaded ${users.length} TikTok users from JSON file`);
493
+
console.log(`Loaded ${users.length} users from JSON file`);
662
494
setStatusMessage(`Loaded ${users.length} users from JSON file`);
663
495
} else if (file.name.endsWith(".txt")) {
664
496
// Direct TXT upload
665
497
users = parseTxtFile(await file.text());
666
-
console.log(`Loaded ${users.length} TikTok users from TXT file`);
498
+
console.log(`Loaded ${users.length} users from TXT file`);
667
499
setStatusMessage(`Loaded ${users.length} users from TXT file`);
668
500
} else if (file.name.endsWith(".zip")) {
669
501
// ZIP upload - find Following.txt OR JSON
···
682
514
if(followingFile) {
683
515
const followingText = await followingFile.async("string");
684
516
users = parseTxtFile(followingText);
685
-
console.log(`Loaded ${users.length} TikTok users from .ZIP file`);
517
+
console.log(`Loaded ${users.length} users from .ZIP file`);
686
518
setStatusMessage(`Loaded ${users.length} users from ZIP file`);
687
519
} else {
688
520
// If no TXT, look for JSON at the top level
···
699
531
700
532
const jsonText = await jsonFileEntry.async("string");
701
533
users = await parseJsonFile(jsonText);
702
-
console.log(`Loaded ${users.length} TikTok users from .ZIP file`);
534
+
console.log(`Loaded ${users.length} users from .ZIP file`);
703
535
setStatusMessage(`Loaded ${users.length} users from ZIP file`);
704
536
}
705
537
} else {
···
752
584
}
753
585
return result;
754
586
}));
587
+
}
588
+
589
+
function toggleExpandResult(index: number) {
590
+
setExpandedResults(prev => {
591
+
const next = new Set(prev);
592
+
if (next.has(index)) next.delete(index);
593
+
else next.add(index);
594
+
return next;
595
+
});
755
596
}
756
597
757
598
// Select all matches across all results - only first match per TT user
···
873
714
total + (result.selectedMatches?.size || 0), 0
874
715
);
875
716
const totalFound = searchResults.filter(r => r.atprotoMatches.length > 0).length;
876
-
const totalSearched = searchResults.filter(r => !r.isSearching).length;
877
717
878
718
return (
879
719
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800">
···
893
733
Skip to main content
894
734
</a>
895
735
896
-
{/* Header */}
897
-
<header className="bg-white dark:bg-gray-800 shadow-sm border-b dark:border-gray-700">
898
-
<div className="px-4 py-4 max-w-2xl mx-auto">
899
-
<div className="flex items-center justify-between">
900
-
<div className="flex items-center space-x-2">
901
-
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center" aria-hidden="true">
902
-
<ArrowRight className="w-4 h-4 text-white" />
903
-
</div>
904
-
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">ATlast</h1>
905
-
</div>
906
-
{session && (
907
-
<div className="flex items-center space-x-3">
908
-
<div className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-300">
909
-
<User className="w-4 h-4" aria-hidden="true" />
910
-
<span>@{session.handle}</span>
911
-
</div>
912
-
<button
913
-
onClick={handleLogout}
914
-
className="flex items-center space-x-1 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
915
-
aria-label="Log out"
916
-
>
917
-
<LogOut className="w-4 h-4" />
918
-
<span className="hidden sm:inline">Logout</span>
919
-
</button>
920
-
</div>
921
-
)}
922
-
</div>
923
-
</div>
924
-
</header>
925
-
926
736
<main id="main-content">
927
737
{/* Checking Session */}
928
738
{currentStep === 'checking' && (
···
937
747
</div>
938
748
)}
939
749
940
-
{/* Login Step */}
750
+
{/* Home / Login Step */}
941
751
{currentStep === 'login' && (
942
-
<div className="p-6 max-w-md mx-auto mt-8">
943
-
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6">
944
-
<div className="text-center">
945
-
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center">
946
-
<Users className="w-8 h-8 text-white" />
752
+
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:via-gray-850 dark:to-gray-800">
753
+
<div className="max-w-6xl mx-auto px-4 py-12">
754
+
{/* Welcome Section */}
755
+
<div className="text-center mb-16">
756
+
<div className="inline-flex items-center justify-center w-24 h-24 bg-gradient-to-br from-blue-500 to-purple-600 rounded-3xl mb-6 shadow-xl">
757
+
<Heart className="w-12 h-12 text-white" />
947
758
</div>
948
-
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Welcome!</h2>
949
-
<p className="text-gray-600 dark:text-gray-300">Connect your ATmosphere account to sync your TikTok follows</p>
759
+
<h1 className="text-5xl md:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4">
760
+
Welcome to ATlast
761
+
</h1>
762
+
<p className="text-xl md:text-2xl text-gray-700 dark:text-gray-300 mb-8 max-w-2xl mx-auto">
763
+
Reunite with your community on the ATmosphere
764
+
</p>
950
765
</div>
766
+
{/* Value Props */}
767
+
<div className="grid md:grid-cols-3 gap-6 mb-16 max-w-5xl mx-auto">
768
+
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-100 dark:border-gray-700">
769
+
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center mb-4">
770
+
<Upload className="w-6 h-6 text-blue-600 dark:text-blue-400" />
771
+
</div>
772
+
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2">
773
+
Upload Your Data
774
+
</h3>
775
+
<p className="text-gray-600 dark:text-gray-400">
776
+
Import your following lists from Twitter, TikTok, Instagram, and more. Your data stays private.
777
+
</p>
778
+
</div>
951
779
952
-
<form
953
-
onSubmit={(e) => {
954
-
e.preventDefault();
955
-
if (!useAppPassword) loginWithOAuth();
956
-
else loginWithAppPassword();
957
-
}}
958
-
className="space-y-4"
959
-
>
960
-
<div>
961
-
<label htmlFor="user-handle" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
962
-
User Handle
963
-
</label>
964
-
<input
965
-
id="user-handle"
966
-
type="text"
967
-
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px] bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
968
-
placeholder="yourhandle.atproto.social"
969
-
value={handle}
970
-
onChange={(e) => setHandle(e.target.value)}
971
-
aria-required="true"
972
-
autoComplete="username"
973
-
/>
780
+
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-100 dark:border-gray-700">
781
+
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-xl flex items-center justify-center mb-4">
782
+
<Search className="w-6 h-6 text-purple-600 dark:text-purple-400" />
783
+
</div>
784
+
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2">
785
+
Find Matches
786
+
</h3>
787
+
<p className="text-gray-600 dark:text-gray-400">
788
+
We'll search the ATmosphere to find which of your follows have already migrated.
789
+
</p>
974
790
</div>
975
791
976
-
{!useAppPassword ? (
977
-
<>
978
-
<button
979
-
type="submit"
980
-
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3 rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 min-h-[44px]"
981
-
>
982
-
Connect to the ATmosphere
983
-
</button>
792
+
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-100 dark:border-gray-700">
793
+
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900/30 rounded-xl flex items-center justify-center mb-4">
794
+
<Heart className="w-6 h-6 text-pink-600 dark:text-pink-400" />
795
+
</div>
796
+
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2">
797
+
Reconnect Instantly
798
+
</h3>
799
+
<p className="text-gray-600 dark:text-gray-400">
800
+
Follow everyone at once or pick and choose. Build your community on the ATmosphere.
801
+
</p>
802
+
</div>
803
+
</div>
984
804
985
-
<button
986
-
type="button"
987
-
onClick={() => setUseAppPassword(true)}
988
-
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded min-h-[44px]"
989
-
>
990
-
Use App Password instead
991
-
</button>
992
-
</>
993
-
) : (
994
-
<>
805
+
{/* Login Card */}
806
+
<div className="max-w-md mx-auto">
807
+
<div className="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl p-8 border border-gray-100 dark:border-gray-700">
808
+
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2 text-center">
809
+
Get Started
810
+
</h2>
811
+
<p className="text-gray-600 dark:text-gray-400 text-center mb-6">
812
+
Connect your ATmosphere account to begin finding your people
813
+
</p>
814
+
815
+
<form onSubmit={loginWithOAuth} className="space-y-4">
995
816
<div>
996
-
<label htmlFor="app-password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
997
-
App Password
817
+
<label htmlFor="atproto-handle" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
818
+
Your ATmosphere Handle
998
819
</label>
999
820
<input
1000
-
id="app-password"
1001
-
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px] bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
1002
-
type="password"
1003
-
placeholder="Not your regular password!"
1004
-
value={appPassword}
1005
-
onChange={(e) => setAppPassword(e.target.value)}
821
+
id="atproto-handle"
822
+
type="text"
823
+
value={handle}
824
+
onChange={(e) => setHandle(e.target.value)}
825
+
placeholder="yourname.bsky.social"
826
+
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
1006
827
aria-required="true"
1007
-
autoComplete="off"
1008
-
aria-describedby="password-help"
828
+
aria-describedby="handle-description"
1009
829
/>
1010
-
<p id="password-help" className="text-xs text-gray-500 dark:text-gray-300 mt-1">
1011
-
Generate this in your Bluesky settings
830
+
<p id="handle-description" className="text-xs text-gray-500 dark:text-gray-400 mt-2">
831
+
Enter your full ATmosphere handle (e.g., username.bsky.social)
1012
832
</p>
1013
833
</div>
1014
834
1015
835
<button
1016
-
type="button"
1017
-
onClick={() => setUseAppPassword(true)}
1018
-
className="w-full text-sm text-gray-600 hover:text-gray-900 underline py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded min-h-[44px]"
836
+
type="submit"
837
+
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 rounded-xl font-bold text-lg transition-all shadow-lg hover:shadow-xl focus:ring-4 focus:ring-purple-300 dark:focus:ring-purple-800 focus:outline-none"
838
+
aria-label="Connect to the ATmosphere"
1019
839
>
1020
-
Use App Password instead
840
+
Connect to the ATmosphere
1021
841
</button>
842
+
</form>
1022
843
1023
-
<button
1024
-
type="button"
1025
-
onClick={() => setUseAppPassword(false)}
1026
-
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded min-h-[44px]"
1027
-
>
1028
-
Use OAuth instead (recommended)
1029
-
</button>
1030
-
</>
1031
-
)}
1032
-
</form>
1033
-
</div>
1034
-
</div>
1035
-
)}
844
+
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
845
+
<div className="flex items-start space-x-2 text-sm text-gray-600 dark:text-gray-400">
846
+
<svg className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
847
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
848
+
</svg>
849
+
<div>
850
+
<p className="font-medium text-gray-700 dark:text-gray-300">Secure OAuth Connection</p>
851
+
<p className="text-xs mt-1">We use official AT Protocol OAuth. We never see your password and you can revoke access anytime.</p>
852
+
</div>
853
+
</div>
854
+
</div>
855
+
</div>
1036
856
1037
-
{/* Home/Dashboard Step */}
1038
-
{currentStep === 'home' && (
1039
-
<div className="p-6 max-w-md mx-auto mt-8">
1040
-
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6">
1041
-
<div className="text-center">
1042
-
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center">
1043
-
<Home className="w-8 h-8 text-white" />
857
+
{/* Privacy Notice */}
858
+
<div className="mt-8 text-center">
859
+
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-md mx-auto">
860
+
Your data is processed locally and never stored on our servers. We only help you find matches and reconnect with your community.
861
+
</p>
1044
862
</div>
1045
-
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
1046
-
Welcome back!
1047
-
</h2>
1048
-
<p className="text-gray-600 dark:text-gray-300">
1049
-
What would you like to do?
1050
-
</p>
1051
863
</div>
1052
864
1053
-
<div className="space-y-3">
1054
-
<button
1055
-
onClick={() => setCurrentStep('upload')}
1056
-
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 px-6 rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 min-h-[56px] flex items-center justify-center space-x-3"
1057
-
>
1058
-
<Upload className="w-5 h-5" />
1059
-
<span>Upload TikTok Data</span>
1060
-
</button>
1061
-
1062
-
<button
1063
-
onClick={() => alert('View previous results feature coming soon!')}
1064
-
className="w-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 py-4 px-6 rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 min-h-[56px] flex items-center justify-center space-x-3"
1065
-
disabled
1066
-
>
1067
-
<FileText className="w-5 h-5" />
1068
-
<span>View Previous Results</span>
1069
-
<span className="text-xs bg-gray-300 dark:bg-gray-600 px-2 py-1 rounded">Coming Soon</span>
1070
-
</button>
865
+
{/* How It Works */}
866
+
<div className="mt-16 max-w-4xl mx-auto">
867
+
<h2 className="text-2xl font-bold text-center text-gray-900 dark:text-gray-100 mb-8">
868
+
How It Works
869
+
</h2>
870
+
<div className="grid md:grid-cols-4 gap-4">
871
+
<div className="text-center">
872
+
<div className="w-12 h-12 bg-blue-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true">
873
+
1
874
+
</div>
875
+
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Connect</h3>
876
+
<p className="text-sm text-gray-600 dark:text-gray-400">Sign in with your ATmosphere account</p>
877
+
</div>
878
+
<div className="text-center">
879
+
<div className="w-12 h-12 bg-purple-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true">
880
+
2
881
+
</div>
882
+
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Upload</h3>
883
+
<p className="text-sm text-gray-600 dark:text-gray-400">Import your following data from other platforms</p>
884
+
</div>
885
+
<div className="text-center">
886
+
<div className="w-12 h-12 bg-pink-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true">
887
+
3
888
+
</div>
889
+
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Match</h3>
890
+
<p className="text-sm text-gray-600 dark:text-gray-400">We find your people on the ATmosphere</p>
891
+
</div>
892
+
<div className="text-center">
893
+
<div className="w-12 h-12 bg-orange-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true">
894
+
4
895
+
</div>
896
+
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Follow</h3>
897
+
<p className="text-sm text-gray-600 dark:text-gray-400">Reconnect with your community</p>
898
+
</div>
899
+
</div>
1071
900
</div>
1072
901
</div>
1073
902
</div>
1074
903
)}
1075
904
1076
-
{/* Upload Step */}
1077
-
{currentStep === 'upload' && (
1078
-
<div className="p-6 max-w-md mx-auto mt-8">
1079
-
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6">
1080
-
<div className="flex items-center justify-between">
1081
-
<button
1082
-
onClick={() => setCurrentStep('home')}
1083
-
className="flex items-center space-x-1 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded px-2 py-1"
1084
-
>
1085
-
<ChevronRight className="w-4 h-4 rotate-180" />
1086
-
<span>Back</span>
1087
-
</button>
1088
-
</div>
905
+
{/* Home/Dashboard Step */}
906
+
{currentStep === 'home' && (
907
+
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
908
+
{/* Header */}
909
+
<AppHeader session={session} onLogout={handleLogout} onNavigate={setCurrentStep} currentStep={currentStep} />
1089
910
1090
-
<div className="text-center">
1091
-
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center">
1092
-
<FileText className="w-8 h-8 text-white" />
911
+
<div className="max-w-4xl mx-auto px-4 py-8">
912
+
{/* Upload New Data Section */}
913
+
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 mb-6">
914
+
<div className="flex items-center space-x-3 mb-4">
915
+
<Upload className="w-6 h-6 text-blue-600 dark:text-blue-400" />
916
+
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
917
+
Upload Following Data
918
+
</h2>
919
+
</div>
920
+
<p className="text-gray-600 dark:text-gray-400 mb-6">
921
+
Upload your exported data from any platform to find matches on the ATmosphere
922
+
</p>
923
+
924
+
{/* Platform Grid */}
925
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
926
+
{Object.entries(PLATFORMS).map(([key, p]) => {
927
+
const PlatformIcon = p.icon;
928
+
const isEnabled = key === 'tiktok';
929
+
return (
930
+
<div
931
+
key={key}
932
+
className={`relative p-4 rounded-xl border-2 transition-all ${
933
+
isEnabled
934
+
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 hover:shadow-lg cursor-pointer'
935
+
: 'border-gray-200 dark:border-gray-800 opacity-50 cursor-not-allowed'
936
+
}`}
937
+
title={isEnabled ? `Upload ${p.name} data` : 'Coming soon'}
938
+
>
939
+
<PlatformIcon className={`w-8 h-8 mx-auto mb-2 ${isEnabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-700'}`} />
940
+
<div className="text-sm font-medium text-center text-gray-900 dark:text-gray-100">
941
+
{p.name}
942
+
</div>
943
+
{!isEnabled && (
944
+
<div className="absolute top-2 right-2">
945
+
<span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded-full">
946
+
Soon
947
+
</span>
948
+
</div>
949
+
)}
950
+
</div>
951
+
);
952
+
})}
1093
953
</div>
1094
-
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Upload Your Data</h2>
1095
-
<p className="text-gray-600 dark:text-gray-300">Upload your TikTok following data to find matches</p>
1096
-
</div>
1097
-
1098
-
<div className="space-y-4">
954
+
955
+
{/* Upload Area */}
1099
956
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 text-center hover:border-blue-400 dark:hover:border-blue-500 focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-colors">
1100
957
<Upload className="w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-3" aria-hidden="true" />
1101
958
<p className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-1">Choose File</p>
1102
-
<p className="text-sm text-gray-500 dark:text-gray-300 mb-3">Following.txt or TikTok data ZIP</p>
959
+
<p className="text-sm text-gray-500 dark:text-gray-300 mb-3">TikTok Following.txt, JSON, or ZIP export</p>
1103
960
1104
961
<input
1105
962
id="file-upload"
···
1125
982
</label>
1126
983
</div>
1127
984
1128
-
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-4" role="region" aria-label="Instructions for getting your TikTok data">
1129
-
<h3 className="font-medium text-blue-900 dark:text-blue-200 mb-2">How to get your data:</h3>
1130
-
<ol className="text-sm text-blue-800 dark:text-blue-300 space-y-1 list-decimal list-inside">
1131
-
<li>Open TikTok app → Profile → Settings and privacy → Account → Download your data</li>
1132
-
<li>Request data → Select "Request data"</li>
1133
-
<li>Wait for notification your download is ready</li>
1134
-
<li>Navigate back to Download your data</li>
1135
-
<li>Download data → Select</li>
1136
-
<li>Upload the Following.txt file here</li>
1137
-
</ol>
985
+
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
986
+
<p className="text-sm text-blue-900 dark:text-blue-300">
987
+
💡 <strong>How to get your TikTok data:</strong> Open TikTok → Profile → Settings → Account → Download your data → Request data → Wait for notification → Download → Upload Following.txt here
988
+
</p>
1138
989
</div>
1139
990
</div>
1140
991
</div>
···
1143
994
1144
995
{/* Loading Step */}
1145
996
{currentStep === 'loading' && (
1146
-
<div className="p-6 max-w-2xl mx-auto mt-8">
1147
-
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 space-y-6">
1148
-
<div className="text-center">
1149
-
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center">
1150
-
<Search className="w-8 h-8 text-white animate-pulse" aria-hidden="true" />
997
+
<div>
998
+
<AppHeader session={session} onLogout={handleLogout} onNavigate={setCurrentStep} currentStep={currentStep} />
999
+
<div className="max-w-3xl mx-auto px-4 py-8">
1000
+
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8">
1001
+
<div className="text-center mb-6">
1002
+
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center">
1003
+
<Search className="w-8 h-8 text-white animate-pulse" aria-hidden="true" />
1004
+
</div>
1005
+
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Finding Your People</h2>
1006
+
<p className="text-gray-600 dark:text-gray-300">Searching the ATmosphere for your follows...</p>
1151
1007
</div>
1152
-
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Finding Your People</h2>
1153
-
<p className="text-gray-600 dark:text-gray-300">Searching the ATmosphere for your TikTok follows...</p>
1154
-
</div>
1155
1008
1156
-
<div className="bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-700 dark:to-gray-600 rounded-xl p-6" role="region" aria-label="Search progress">
1157
-
<div className="grid grid-cols-3 gap-4 text-center mb-4">
1158
-
<div>
1159
-
<div className="text-3xl font-bold text-gray-900 dark:text-gray-300" aria-label={`${searchProgress.searched} searched`}>{searchProgress.searched}</div>
1160
-
<div className="text-sm text-gray-600 dark:text-gray-300">Searched</div>
1009
+
<div className="space-y-4">
1010
+
<div className="grid grid-cols-3 gap-4 text-center">
1011
+
<div>
1012
+
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100" aria-label={`${searchProgress.searched} searched`}>{searchProgress.searched}</div>
1013
+
<div className="text-sm text-gray-600 dark:text-gray-300">Searched</div>
1014
+
</div>
1015
+
<div>
1016
+
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400" aria-label={`${searchProgress.found} found`}>{searchProgress.found}</div>
1017
+
<div className="text-sm text-gray-600 dark:text-gray-300">Found</div>
1018
+
</div>
1019
+
<div>
1020
+
<div className="text-3xl font-bold text-gray-400 dark:text-gray-500" aria-label={`${searchProgress.total} total`}>{searchProgress.total}</div>
1021
+
<div className="text-sm text-gray-600 dark:text-gray-300">Total</div>
1022
+
</div>
1161
1023
</div>
1162
-
<div>
1163
-
<div className="text-3xl font-bold text-blue-600 dark:text-blue-500" aria-label={`${searchProgress.found} found`}>{searchProgress.found}</div>
1164
-
<div className="text-sm text-gray-600 dark:text-gray-300">Found</div>
1024
+
1025
+
<div className="w-full bg-gray-200 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3" role="progressbar" aria-valuenow={searchProgress.total > 0 ? Math.round((searchProgress.searched / searchProgress.total) * 100) : 0} aria-valuemin={0} aria-valuemax={100}>
1026
+
<div
1027
+
className="bg-gradient-to-r from-blue-500 to-purple-600 h-full rounded-full transition-all"
1028
+
style={{ width: `${searchProgress.total > 0 ? (searchProgress.searched / searchProgress.total) * 100 : 0}%` }}
1029
+
/>
1165
1030
</div>
1166
-
<div>
1167
-
<div className="text-3xl font-bold text-gray-400 dark:text-gray-900" aria-label={`${searchProgress.total} total`}>{searchProgress.total}</div>
1168
-
<div className="text-sm text-gray-600 dark:text-gray-300">Total</div>
1031
+
<div className="space-y-3">
1032
+
{[...Array(5)].map((_, i) => (
1033
+
<div key={i} className="animate-pulse flex items-center space-x-3 p-4 bg-gray-50 dark:bg-gray-700 rounded-xl">
1034
+
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-full" />
1035
+
<div className="flex-1 space-y-2">
1036
+
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-3/4" />
1037
+
<div className="h-3 bg-gray-200 dark:bg-gray-600 rounded w-1/2" />
1038
+
</div>
1039
+
</div>
1040
+
))}
1169
1041
</div>
1170
1042
</div>
1171
-
1172
-
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden" role="progressbar" aria-valuenow={searchProgress.total > 0 ? Math.round((searchProgress.searched / searchProgress.total) * 100) : 0} aria-valuemin={0} aria-valuemax={100}>
1173
-
<div
1174
-
className="bg-gradient-to-r from-blue-500 to-purple-600 h-full rounded-full transition-all duration-500 ease-out"
1175
-
style={{ width: `${searchProgress.total > 0 ? (searchProgress.searched / searchProgress.total) * 100 : 0}%` }}
1176
-
/>
1177
-
</div>
1178
-
<div className="text-center mt-2 text-sm text-gray-600 dark:text-gray-300" aria-hidden="true">
1179
-
{searchProgress.total > 0 ? Math.round((searchProgress.searched / searchProgress.total) * 100) : 0}% complete
1180
-
</div>
1181
1043
</div>
1182
1044
</div>
1183
1045
</div>
···
1185
1047
1186
1048
{/* Results */}
1187
1049
{currentStep === 'results' && (
1188
-
<div className="pb-20">
1189
-
<div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700">
1190
-
<div className="px-4 py-4 max-w-2xl mx-auto">
1191
-
<div className="flex items-center justify-between mb-3">
1192
-
<div className="flex items-center space-x-3">
1193
-
<button
1194
-
onClick={() => setCurrentStep('home')}
1195
-
className="flex items-center space-x-1 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded px-2 py-1"
1196
-
>
1197
-
<ChevronRight className="w-4 h-4 rotate-180" />
1198
-
<span>Home</span>
1199
-
</button>
1050
+
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 pb-24">
1051
+
<AppHeader session={session} onLogout={handleLogout} onNavigate={setCurrentStep} currentStep={currentStep} />
1052
+
{/* Platform Info Banner */}
1053
+
<div className="bg-gradient-to-r from-black via-gray-800 to-cyan-400 text-white">
1054
+
<div className="max-w-3xl mx-auto px-4 py-6">
1055
+
<div className="flex items-center justify-between">
1056
+
<div className="flex items-center space-x-4">
1057
+
<Video className="w-12 h-12" />
1200
1058
<div>
1201
-
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Results</h2>
1202
-
<p className="text-sm text-gray-600 dark:text-gray-300">
1203
-
{totalFound} of {searchResults.length} users found
1059
+
<h2 className="text-xl font-bold">TikTok Matches</h2>
1060
+
<p className="text-white/90 text-sm">
1061
+
{totalFound} matches from {searchResults.length} follows
1204
1062
</p>
1205
1063
</div>
1206
1064
</div>
1207
-
<div className="text-right">
1208
-
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{totalSelected}</div>
1209
-
<div className="text-xs text-gray-500 dark:text-gray-200">selected</div>
1210
-
</div>
1065
+
{totalSelected > 0 && (
1066
+
<div className="text-right">
1067
+
<div className="text-2xl font-bold">{totalSelected}</div>
1068
+
<div className="text-xs text-white/80">selected</div>
1069
+
</div>
1070
+
)}
1211
1071
</div>
1212
-
1213
-
<div className="flex space-x-2">
1214
-
<button
1215
-
onClick={selectAllMatches}
1216
-
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 min-h-[44px]"
1217
-
type="button"
1218
-
aria-label="Select all top matches"
1219
-
>
1220
-
Select All
1221
-
</button>
1222
-
<button
1223
-
onClick={deselectAllMatches}
1224
-
className="flex-1 bg-gray-500 hover:bg-gray-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 min-h-[44px]"
1225
-
type="button"
1226
-
aria-label="Clear all selections"
1227
-
>
1228
-
Clear
1229
-
</button>
1230
-
</div>
1072
+
</div>
1073
+
</div>
1074
+
1075
+
{/* Action Buttons */}
1076
+
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
1077
+
<div className="max-w-3xl mx-auto px-4 py-3 flex space-x-2">
1078
+
<button
1079
+
onClick={selectAllMatches}
1080
+
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
1081
+
type="button"
1082
+
>
1083
+
Select All
1084
+
</button>
1085
+
<button
1086
+
onClick={deselectAllMatches}
1087
+
className="flex-1 bg-gray-500 hover:bg-gray-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
1088
+
type="button"
1089
+
>
1090
+
Clear
1091
+
</button>
1231
1092
</div>
1232
1093
</div>
1233
1094
1234
-
{/* Results List */}
1235
-
<div className="space-y-2 p-4 max-w-2xl mx-auto" role="list" aria-label="Search results">
1236
-
{searchResults.map((result, index) => (
1237
-
<div key={index} className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border dark:border-gray-700" role="listitem">
1238
-
<div className="p-4">
1239
-
{/* TikTok User Header */}
1240
-
<div className="mb-3">
1241
-
<div className="text-xs text-gray-500 dark:text-gray-200 uppercase tracking-wide mb-1" aria-hidden="true">TikTok</div>
1242
-
<div className="font-semibold text-gray-900 dark:text-gray-100 text-lg">
1243
-
<span className="sr-only">TikTok user </span>
1244
-
@{result.tiktokUser.username}
1095
+
{/* Feed Results */}
1096
+
<div className="max-w-3xl mx-auto px-4 py-4 space-y-4">
1097
+
{searchResults.map((item, idx) => {
1098
+
const isExpanded = expandedResults.has(idx);
1099
+
const displayMatches = isExpanded ? item.atprotoMatches : item.atprotoMatches.slice(0, 1);
1100
+
const hasMoreMatches = item.atprotoMatches.length > 1;
1101
+
1102
+
return (
1103
+
<div
1104
+
key={idx}
1105
+
className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden"
1106
+
>
1107
+
{/* Source User (minimal info - just username from TikTok) */}
1108
+
<div className="p-4 bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
1109
+
<div className="flex items-center space-x-3">
1110
+
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-black via-gray-800 to-cyan-400 flex items-center justify-center text-white font-bold">
1111
+
{item.tiktokUser.username.charAt(0).toUpperCase()}
1112
+
</div>
1113
+
<div className="flex-1">
1114
+
<div className="font-bold text-gray-900 dark:text-gray-100">
1115
+
@{item.tiktokUser.username}
1116
+
</div>
1117
+
<div className="text-sm text-gray-500 dark:text-gray-400">
1118
+
from TikTok
1119
+
</div>
1120
+
</div>
1121
+
<div className="text-xs px-2 py-1 rounded-full bg-black dark:bg-cyan-400 text-white dark:text-black">
1122
+
{item.atprotoMatches.length} {item.atprotoMatches.length === 1 ? 'match' : 'matches'}
1123
+
</div>
1245
1124
</div>
1246
1125
</div>
1247
1126
1248
-
{/* ATmosphere Matches */}
1249
-
{result.atprotoMatches.length > 0 ? (
1250
-
<div className="space-y-2">
1251
-
<div className="sr-only">AT matches:</div>
1252
-
<MatchCarousel
1253
-
matches={result.atprotoMatches}
1254
-
selectedDids={result.selectedMatches || new Set()}
1255
-
onToggleSelection={(did) => toggleMatchSelection(index, did)}
1256
-
cardRef={{ current: resultCardRefs.current[index] || null }}
1257
-
/>
1258
-
</div>
1259
-
) : (
1260
-
<div className="text-center py-2 text-gray-400" role="status">
1261
-
<div className="text-sm">No matches found</div>
1262
-
</div>
1263
-
)}
1127
+
{/* Bluesky Matches (rich info from API) */}
1128
+
<div className="p-4">
1129
+
{item.atprotoMatches.length === 0 ? (
1130
+
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
1131
+
<MessageCircle className="w-8 h-8 mx-auto mb-2 opacity-50" />
1132
+
<p className="text-sm">Not found on Bluesky yet</p>
1133
+
</div>
1134
+
) : (
1135
+
<div className="space-y-3">
1136
+
{displayMatches.map((match) => {
1137
+
const isFollowed = match.followed;
1138
+
const isSelected = item.selectedMatches?.has(match.did);
1139
+
return (
1140
+
<div
1141
+
key={match.did}
1142
+
className="flex items-start space-x-3 p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-all"
1143
+
>
1144
+
{/* Avatar */}
1145
+
{match.avatar ? (
1146
+
<img
1147
+
src={match.avatar}
1148
+
alt="User avatar, description not provided"
1149
+
className="w-12 h-12 rounded-full object-cover flex-shrink-0"
1150
+
/>
1151
+
) : (
1152
+
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center flex-shrink-0">
1153
+
<span className="text-white font-bold">
1154
+
{match.handle.charAt(0).toUpperCase()}
1155
+
</span>
1156
+
</div>
1157
+
)}
1158
+
1159
+
{/* Match Info */}
1160
+
<div className="flex-1 min-w-0">
1161
+
{match.displayName && (
1162
+
<div className="font-semibold text-gray-900 dark:text-gray-100">
1163
+
{match.displayName}
1164
+
</div>
1165
+
)}
1166
+
<div className="text-sm text-gray-600 dark:text-gray-400">
1167
+
@{match.handle}
1168
+
</div>
1169
+
{match.description && (
1170
+
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">{match.description}</div>
1171
+
)}
1172
+
<div className="flex items-center space-x-3 mt-2">
1173
+
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 px-2 py-1 rounded-full font-medium">
1174
+
{match.matchScore}% match
1175
+
</span>
1176
+
</div>
1177
+
</div>
1178
+
1179
+
{/* Select/Follow Button */}
1180
+
<button
1181
+
onClick={() => toggleMatchSelection(idx, match.did)}
1182
+
disabled={isFollowed}
1183
+
className={`flex items-center space-x-1 px-3 py-2 rounded-full font-medium transition-all flex-shrink-0 ${
1184
+
isFollowed
1185
+
? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 cursor-not-allowed opacity-60'
1186
+
: isSelected
1187
+
? 'bg-blue-600 text-white'
1188
+
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
1189
+
}`}
1190
+
>
1191
+
{isFollowed ? (
1192
+
<>
1193
+
<Check className="w-4 h-4" />
1194
+
<span className="text-sm">Followed</span>
1195
+
</>
1196
+
) : isSelected ? (
1197
+
<>
1198
+
<Check className="w-4 h-4" />
1199
+
<span className="text-sm">Selected</span>
1200
+
</>
1201
+
) : (
1202
+
<>
1203
+
<UserPlus className="w-4 h-4" />
1204
+
<span className="text-sm">Select</span>
1205
+
</>
1206
+
)}
1207
+
</button>
1208
+
</div>
1209
+
);
1210
+
})}
1211
+
{hasMoreMatches && (
1212
+
<button
1213
+
onClick={() => toggleExpandResult(idx)}
1214
+
className="w-full py-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center justify-center space-x-1"
1215
+
>
1216
+
<span>{isExpanded ? 'Show less' : `Show ${item.atprotoMatches.length - 1} more ${item.atprotoMatches.length - 1 === 1 ? 'match' : 'matches'}`}</span>
1217
+
<ChevronDown className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
1218
+
</button>
1219
+
)}
1220
+
</div>
1221
+
)}
1222
+
</div>
1264
1223
</div>
1265
-
</div>
1266
-
))}
1224
+
);
1225
+
})}
1267
1226
</div>
1227
+
1228
+
{/* Fixed Bottom Action Bar */}
1229
+
{totalSelected > 0 && (
1230
+
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pt-8 pb-6">
1231
+
<div className="max-w-3xl mx-auto px-4">
1232
+
<button
1233
+
onClick={followSelectedUsers}
1234
+
disabled={isFollowing}
1235
+
className="w-full bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 hover:from-blue-600 hover:via-purple-600 hover:to-pink-600 text-white py-5 rounded-2xl font-bold text-lg transition-all shadow-2xl hover:shadow-3xl flex items-center justify-center space-x-3 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none focus:outline-none focus:ring-4 focus:ring-purple-300 dark:focus:ring-purple-800"
1236
+
>
1237
+
<Heart className="w-6 h-6" />
1238
+
<span>Follow {totalSelected} Selected {totalSelected === 1 ? 'User' : 'Users'}</span>
1239
+
</button>
1240
+
</div>
1241
+
</div>
1242
+
)}
1268
1243
</div>
1269
1244
)}
1270
1245
</main>
1271
-
1272
-
{/* Fixed Bottom Action Bar */}
1273
-
{currentStep === 'results' && totalSelected > 0 && (
1274
-
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700 shadow-lg">
1275
-
<div className="p-4 max-w-2xl mx-auto">
1276
-
<button
1277
-
onClick={followSelectedUsers}
1278
-
disabled={isFollowing}
1279
-
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 rounded-xl font-medium text-lg transition-all duration-200 shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed min-h-[56px]"
1280
-
type="button"
1281
-
aria-live="polite"
1282
-
aria-label={isFollowing ? `Following users, please wait` : `Follow ${totalSelected} selected users`}
1283
-
>
1284
-
{isFollowing
1285
-
? "Following Users..."
1286
-
: `Follow ${totalSelected} Selected Users`
1287
-
}
1288
-
</button>
1289
-
</div>
1290
-
</div>
1291
-
)}
1292
1246
</div>
1293
1247
);
1294
1248
}