ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1import { Sparkles } from "lucide-react";
2import { useMemo } from "react";
3import AppHeader from "../components/AppHeader";
4import SearchResultCard from "../components/SearchResultCard";
5import FaviconIcon from "../components/FaviconIcon";
6import type { AtprotoAppId, AtprotoSession, SearchResult } from "../types";
7import { getPlatform, getAtprotoApp } from "../lib/utils/platform";
8import VirtualizedResultsList from "../components/VirtualizedResultsList";
9import Button from "../components/common/Button";
10
11interface ResultsPageProps {
12 session: AtprotoSession | null;
13 onLogout: () => void;
14 onNavigate: (step: "home" | "login") => void;
15 searchResults: SearchResult[];
16 expandedResults: Set<number>;
17 onToggleExpand: (index: number) => void;
18 onToggleMatchSelection: (resultIndex: number, did: string) => void;
19 onSelectAll: () => void;
20 onDeselectAll: () => void;
21 onFollowSelected: () => void;
22 totalSelected: number;
23 totalFound: number;
24 isFollowing: boolean;
25 currentStep: string;
26 sourcePlatform: string;
27 destinationAppId: AtprotoAppId;
28 reducedMotion?: boolean;
29 isDark?: boolean;
30 onToggleTheme?: () => void;
31 onToggleMotion?: () => void;
32}
33
34export default function ResultsPage({
35 session,
36 onLogout,
37 onNavigate,
38 searchResults,
39 expandedResults,
40 onToggleExpand,
41 onToggleMatchSelection,
42 onSelectAll,
43 onDeselectAll,
44 onFollowSelected,
45 totalSelected,
46 totalFound,
47 isFollowing,
48 currentStep,
49 sourcePlatform,
50 destinationAppId,
51 reducedMotion = false,
52 isDark = false,
53 onToggleTheme,
54 onToggleMotion,
55}: ResultsPageProps) {
56 const platform = getPlatform(sourcePlatform);
57 const destinationApp = getAtprotoApp(destinationAppId);
58 const PlatformIcon = platform.icon;
59
60 // Memoize sorted results to avoid re-sorting on every render
61 const sortedResults = useMemo(() => {
62 return [...searchResults].sort((a, b) => {
63 // 1. Users with matches first
64 const aHasMatches = a.atprotoMatches.length > 0 ? 0 : 1;
65 const bHasMatches = b.atprotoMatches.length > 0 ? 0 : 1;
66 if (aHasMatches !== bHasMatches) return aHasMatches - bHasMatches;
67
68 // 2. For matched users, sort by highest posts count of their top match
69 if (a.atprotoMatches.length > 0 && b.atprotoMatches.length > 0) {
70 const aTopPosts = a.atprotoMatches[0]?.postCount || 0;
71 const bTopPosts = b.atprotoMatches[0]?.postCount || 0;
72 if (aTopPosts !== bTopPosts) return bTopPosts - aTopPosts;
73
74 // 3. Then by followers count
75 const aTopFollowers = a.atprotoMatches[0]?.followerCount || 0;
76 const bTopFollowers = b.atprotoMatches[0]?.followerCount || 0;
77 if (aTopFollowers !== bTopFollowers)
78 return bTopFollowers - aTopFollowers;
79 }
80
81 // 4. Username as tiebreaker
82 return a.sourceUser.username.localeCompare(b.sourceUser.username);
83 });
84 }, [searchResults]);
85
86 return (
87 <div className="min-h-screen pb-24">
88 <AppHeader
89 session={session}
90 onLogout={onLogout}
91 onNavigate={onNavigate}
92 currentStep={currentStep}
93 isDark={isDark}
94 reducedMotion={reducedMotion}
95 onToggleTheme={onToggleTheme}
96 onToggleMotion={onToggleMotion}
97 />
98
99 {/* Platform Info Banner */}
100 <div className="bg-firefly-banner dark:bg-firefly-banner-dark text-white relative overflow-hidden">
101 {!reducedMotion && (
102 <div className="absolute inset-0 opacity-20" aria-hidden="true">
103 {[...Array(10)].map((_, i) => {
104 const animations = ["animate-float-1", "animate-float-2", "animate-float-3"];
105 return (
106 <div
107 key={i}
108 className={`absolute w-1 h-1 bg-white rounded-full ${animations[i % 3]}`}
109 style={{
110 left: `${Math.random() * 100}%`,
111 top: `${Math.random() * 100}%`,
112 }}
113 />
114 );
115 })}
116 </div>
117 )}
118 <div className="max-w-3xl mx-auto px-4 py-6 relative">
119 <div className="flex items-center justify-between">
120 <div className="flex items-center space-x-4">
121 <div className="w-12 h-12 bg-white/20 backdrop-blur rounded-xl flex items-center justify-center shadow-lg">
122 <Sparkles className="w-6 h-6 text-white" />
123 </div>
124 <div>
125 <h2 className="text-xl font-bold">
126 {totalFound} Connections Found!
127 </h2>
128 <p className="text-white/95 text-sm">
129 From {searchResults.length} {platform.name} follows
130 </p>
131 </div>
132 </div>
133 {totalSelected > 0 && (
134 <div className="text-right">
135 <div className="text-2xl font-bold">{totalSelected}</div>
136 <div className="text-xs font-medium">selected</div>
137 </div>
138 )}
139 </div>
140 </div>
141 </div>
142
143 {/* Action Buttons */}
144 <div className="bg-white/95 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30 sticky top-0 z-10 backdrop-blur-sm">
145 <div className="max-w-3xl mx-auto px-4 py-3 flex space-x-2">
146 <Button onClick={onSelectAll} variant="primary" className="flex-1">
147 Select All
148 </Button>
149 <Button
150 onClick={onDeselectAll}
151 variant="secondary"
152 className="flex-1"
153 >
154 Clear
155 </Button>
156 </div>
157 </div>
158
159 {/* Feed Results */}
160 <div className="max-w-3xl mx-auto px-4 py-4">
161 <VirtualizedResultsList
162 results={sortedResults}
163 expandedResults={expandedResults}
164 onToggleExpand={onToggleExpand}
165 onToggleMatchSelection={onToggleMatchSelection}
166 sourcePlatform={sourcePlatform}
167 destinationAppId={destinationAppId}
168 />
169 </div>
170
171 {/* Fixed Bottom Action Bar */}
172 {totalSelected > 0 && (
173 <div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-transparent dark:from-slate-900 dark:via-slate-900 dark:to-transparent pt-8 pb-6">
174 <div className="max-w-3xl mx-auto px-4">
175 <button
176 onClick={onFollowSelected}
177 disabled={isFollowing}
178 className="w-full bg-firefly-banner dark:bg-firefly-banner-dark text-white hover:from-amber-600 hover:via-orange-600 hover:to-pink-600 py-5 rounded-2xl font-bold text-lg transition-all shadow-2xl hover:shadow-3xl flex items-center justify-center space-x-3 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
179 >
180 <FaviconIcon
181 url={destinationApp.icon}
182 alt={destinationApp.name}
183 className="w-5 h-5"
184 useButtonStyling={true}
185 />
186 <span>
187 Light Up {totalSelected} Connection
188 {totalSelected === 1 ? "" : "s"}
189 </span>
190 </button>
191 </div>
192 </div>
193 )}
194 </div>
195 );
196}