+135
-101
src/App.tsx
+135
-101
src/App.tsx
···
1
-
import { useState, useRef, useEffect } from "react";
1
+
import { useState, useEffect, useCallback } from "react";
2
2
import { ArrowRight } from "lucide-react";
3
3
import LoginPage from "./pages/Login";
4
4
import HomePage from "./pages/Home";
···
9
9
import { useFollow } from "./hooks/useFollows";
10
10
import { useFileUpload } from "./hooks/useFileUpload";
11
11
import { useTheme } from "./hooks/useTheme";
12
+
import { useNotifications } from "./hooks/useNotifications";
12
13
import Firefly from "./components/Firefly";
14
+
import NotificationContainer from "./components/common/NotificationContainer";
13
15
import { DEFAULT_SETTINGS } from "./types/settings";
14
-
import type { UserSettings } from "./types/settings";
16
+
import type { UserSettings, SearchResult } from "./types";
15
17
import { apiClient } from "./lib/api/client";
16
18
import { ATPROTO_APPS } from "./config/atprotoApps";
17
19
18
20
export default function App() {
19
-
// Auth hook :)
21
+
// Auth hook
20
22
const {
21
23
session,
22
24
currentStep,
···
27
29
logout,
28
30
} = useAuth();
29
31
32
+
// Notifications hook (replaces alerts)
33
+
const { notifications, removeNotification, success, error, info } =
34
+
useNotifications();
35
+
30
36
// Theme hook
31
37
const { isDark, reducedMotion, toggleTheme, toggleMotion } = useTheme();
32
38
33
-
// Add state to track current platform
39
+
// Current platform state
34
40
const [currentPlatform, setCurrentPlatform] = useState<string>("tiktok");
35
-
const saveCalledRef = useRef<string | null>(null); // Track by uploadId
41
+
42
+
// Track saved uploads to prevent duplicates
43
+
const [savedUploads, setSavedUploads] = useState<Set<string>>(new Set());
36
44
37
45
// Settings state
38
46
const [userSettings, setUserSettings] = useState<UserSettings>(() => {
···
45
53
localStorage.setItem("atlast_settings", JSON.stringify(userSettings));
46
54
}, [userSettings]);
47
55
48
-
const handleSettingsUpdate = (newSettings: Partial<UserSettings>) => {
49
-
setUserSettings((prev) => ({ ...prev, ...newSettings }));
50
-
};
56
+
const handleSettingsUpdate = useCallback(
57
+
(newSettings: Partial<UserSettings>) => {
58
+
setUserSettings((prev) => ({ ...prev, ...newSettings }));
59
+
},
60
+
[],
61
+
);
51
62
52
63
// Search hook
53
64
const {
···
77
88
currentDestinationAppId,
78
89
);
79
90
80
-
// File upload hook
91
+
// Save results handler (proper state management)
92
+
const saveResults = useCallback(
93
+
async (uploadId: string, platform: string, results: SearchResult[]) => {
94
+
if (!userSettings.saveData) {
95
+
console.log("Data storage disabled - skipping save to database");
96
+
return;
97
+
}
98
+
99
+
if (savedUploads.has(uploadId)) {
100
+
console.log("Upload already saved:", uploadId);
101
+
return;
102
+
}
103
+
104
+
try {
105
+
setSavedUploads((prev) => new Set(prev).add(uploadId));
106
+
await apiClient.saveResults(uploadId, platform, results);
107
+
console.log("Results saved successfully:", uploadId);
108
+
} catch (err) {
109
+
console.error("Background save failed:", err);
110
+
setSavedUploads((prev) => {
111
+
const next = new Set(prev);
112
+
next.delete(uploadId);
113
+
return next;
114
+
});
115
+
}
116
+
},
117
+
[userSettings.saveData, savedUploads],
118
+
);
119
+
120
+
// File upload handler
81
121
const { handleFileUpload: processFileUpload } = useFileUpload(
82
122
(initialResults, platform) => {
83
123
setCurrentPlatform(platform);
84
-
85
124
setSearchResults(initialResults);
86
125
setCurrentStep("loading");
87
126
···
89
128
const followLexicon =
90
129
ATPROTO_APPS[currentDestinationAppId]?.followLexicon;
91
130
92
-
searchAllUsers(initialResults, setStatusMessage, () => {
93
-
setCurrentStep("results");
131
+
searchAllUsers(
132
+
initialResults,
133
+
setStatusMessage,
134
+
() => {
135
+
setCurrentStep("results");
94
136
95
-
// CONDITIONAL SAVE: Only save if user has enabled data storage
96
-
if (userSettings.saveData) {
97
-
// Prevent duplicate saves
98
-
if (saveCalledRef.current !== uploadId) {
99
-
saveCalledRef.current = uploadId;
100
-
// Need to wait for React to finish updating searchResults state
101
-
// Use a longer delay and access via setSearchResults callback to get final state
102
-
setTimeout(() => {
103
-
setSearchResults((currentResults) => {
104
-
if (currentResults.length > 0) {
105
-
apiClient
106
-
.saveResults(uploadId, platform, currentResults)
107
-
.then(() => {
108
-
// Invalidate cache after successful save
109
-
apiClient.cache.invalidate("uploads");
110
-
apiClient.cache.invalidatePattern("upload-details");
111
-
})
112
-
.catch((err) => {
113
-
console.error("Background save failed:", err);
114
-
});
115
-
}
116
-
return currentResults;
117
-
});
118
-
}, 1000); // Longer delay to ensure all state updates complete
119
-
}
120
-
} else {
121
-
console.log("Data storage disabled - skipping save to database");
122
-
}
123
-
});
137
+
// Save results after search completes
138
+
setTimeout(() => {
139
+
setSearchResults((currentResults) => {
140
+
if (currentResults.length > 0) {
141
+
saveResults(uploadId, platform, currentResults);
142
+
}
143
+
return currentResults;
144
+
});
145
+
}, 1000);
146
+
},
147
+
followLexicon,
148
+
);
124
149
},
125
150
setStatusMessage,
126
-
userSettings, // Pass userSettings to hook
151
+
userSettings,
127
152
);
128
153
129
154
// Load previous upload handler
130
-
const handleLoadUpload = async (uploadId: string) => {
131
-
try {
132
-
setStatusMessage("Loading previous upload...");
133
-
setCurrentStep("loading");
155
+
const handleLoadUpload = useCallback(
156
+
async (uploadId: string) => {
157
+
try {
158
+
setStatusMessage("Loading previous upload...");
159
+
setCurrentStep("loading");
134
160
135
-
const data = await apiClient.getUploadDetails(uploadId);
161
+
const data = await apiClient.getUploadDetails(uploadId);
136
162
137
-
if (data.results.length === 0) {
138
-
setSearchResults([]);
139
-
setCurrentPlatform("tiktok");
140
-
setCurrentStep("home");
141
-
setStatusMessage("No previous results found.");
142
-
return;
143
-
}
163
+
if (data.results.length === 0) {
164
+
setSearchResults([]);
165
+
setCurrentPlatform("tiktok");
166
+
setCurrentStep("home");
167
+
info("No previous results found.");
168
+
return;
169
+
}
144
170
145
-
const platform = "tiktok"; // Default, will be updated when we add platform to upload details
146
-
setCurrentPlatform(platform);
147
-
saveCalledRef.current = null;
171
+
const platform = "tiktok";
172
+
setCurrentPlatform(platform);
148
173
149
-
// Convert the loaded results to SearchResult format with selectedMatches
150
-
const loadedResults = data.results.map((result) => ({
151
-
...result,
152
-
sourcePlatform: platform,
153
-
isSearching: false,
154
-
selectedMatches: new Set<string>(
155
-
result.atprotoMatches
156
-
.filter((match) => !match.followed)
157
-
.slice(0, 1)
158
-
.map((match) => match.did),
159
-
),
160
-
}));
174
+
const loadedResults: SearchResult[] = data.results.map((result) => ({
175
+
...result,
176
+
sourcePlatform: platform,
177
+
isSearching: false,
178
+
selectedMatches: new Set<string>(
179
+
result.atprotoMatches
180
+
.filter((match) => !match.followed)
181
+
.slice(0, 1)
182
+
.map((match) => match.did),
183
+
),
184
+
}));
161
185
162
-
setSearchResults(loadedResults);
163
-
setCurrentStep("results");
164
-
setStatusMessage(
165
-
`Loaded ${loadedResults.length} results from previous upload`,
166
-
);
167
-
} catch (error) {
168
-
console.error("Failed to load upload:", error);
169
-
setStatusMessage("Failed to load previous upload");
170
-
setCurrentStep("home");
171
-
alert("Failed to load previous upload. Please try again.");
172
-
}
173
-
};
186
+
setSearchResults(loadedResults);
187
+
setCurrentStep("results");
188
+
success(`Loaded ${loadedResults.length} results from previous upload`);
189
+
} catch (err) {
190
+
console.error("Failed to load upload:", err);
191
+
error("Failed to load previous upload. Please try again.");
192
+
setCurrentStep("home");
193
+
}
194
+
},
195
+
[setStatusMessage, setCurrentStep, setSearchResults, info, error, success],
196
+
);
174
197
175
198
// Login handler
176
-
const handleLogin = async (handle: string) => {
177
-
if (!handle?.trim()) {
178
-
setStatusMessage("Please enter your handle");
179
-
alert("Please enter your handle");
180
-
return;
181
-
}
199
+
const handleLogin = useCallback(
200
+
async (handle: string) => {
201
+
if (!handle?.trim()) {
202
+
error("Please enter your handle");
203
+
return;
204
+
}
182
205
183
-
try {
184
-
await login(handle);
185
-
} catch (err) {
186
-
console.error("OAuth error:", err);
187
-
const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`;
188
-
setStatusMessage(errorMsg);
189
-
alert(errorMsg);
190
-
}
191
-
};
206
+
try {
207
+
await login(handle);
208
+
} catch (err) {
209
+
console.error("OAuth error:", err);
210
+
const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`;
211
+
setStatusMessage(errorMsg);
212
+
error(errorMsg);
213
+
}
214
+
},
215
+
[login, error, setStatusMessage],
216
+
);
192
217
193
218
// Logout handler
194
-
const handleLogout = async () => {
219
+
const handleLogout = useCallback(async () => {
195
220
try {
196
221
await logout();
197
222
setSearchResults([]);
198
223
setCurrentPlatform("tiktok");
199
-
} catch (error) {
200
-
alert("Failed to logout. Please try again.");
224
+
setSavedUploads(new Set());
225
+
success("Logged out successfully");
226
+
} catch (err) {
227
+
error("Failed to logout. Please try again.");
201
228
}
202
-
};
229
+
}, [logout, setSearchResults, success, error]);
203
230
204
231
return (
205
232
<div className="min-h-screen relative overflow-hidden">
233
+
{/* Notification Container */}
234
+
<NotificationContainer
235
+
notifications={notifications}
236
+
onRemove={removeNotification}
237
+
/>
238
+
206
239
{/* Firefly particles - only render if motion not reduced */}
207
240
{!reducedMotion && (
208
241
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
···
235
268
{currentStep === "checking" && (
236
269
<div className="p-6 max-w-md mx-auto mt-8">
237
270
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4">
238
-
<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">
271
+
<div className="w-16 h-16 bg-firefly-banner dark:bg-firefly-banner-dark text-white rounded-2xl mx-auto flex items-center justify-center">
239
272
<ArrowRight className="w-8 h-8 text-white animate-pulse" />
240
273
</div>
241
274
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
···
286
319
currentStep={currentStep}
287
320
sourcePlatform={currentPlatform}
288
321
isDark={isDark}
322
+
reducedMotion={reducedMotion}
289
323
onToggleTheme={toggleTheme}
290
324
onToggleMotion={toggleMotion}
291
325
/>
+155
-155
src/components/SearchResultCard.tsx
+155
-155
src/components/SearchResultCard.tsx
···
1
-
import {
2
-
MessageCircle,
3
-
Check,
4
-
UserPlus,
5
-
ChevronDown,
6
-
UserCheck,
7
-
} from "lucide-react";
1
+
import React, { useMemo } from "react";
2
+
import { MessageCircle, ChevronDown } from "lucide-react";
8
3
import type { SearchResult } from "../types";
9
4
import { getAtprotoAppWithFallback } from "../lib/utils/platform";
10
5
import type { AtprotoAppId } from "../types/settings";
11
6
import AvatarWithFallback from "./common/AvatarWithFallback";
7
+
import FollowButton from "./common/FollowButton";
12
8
13
9
interface SearchResultCardProps {
14
10
result: SearchResult;
···
20
16
destinationAppId?: AtprotoAppId;
21
17
}
22
18
23
-
export default function SearchResultCard({
24
-
result,
25
-
resultIndex,
26
-
isExpanded,
27
-
onToggleExpand,
28
-
onToggleMatchSelection,
29
-
sourcePlatform,
30
-
destinationAppId = "bluesky",
31
-
}: SearchResultCardProps) {
32
-
const displayMatches = isExpanded
33
-
? result.atprotoMatches
34
-
: result.atprotoMatches.slice(0, 1);
35
-
const hasMoreMatches = result.atprotoMatches.length > 1;
36
-
const currentApp = getAtprotoAppWithFallback(destinationAppId);
37
-
const currentLexicon = currentApp?.followLexicon || "app.bsky.graph.follow";
38
-
19
+
// Memoize the match item to prevent unnecessary re-renders
20
+
const MatchItem = React.memo<{
21
+
match: any;
22
+
isSelected: boolean;
23
+
isFollowed: boolean;
24
+
currentAppName: string;
25
+
onToggle: () => void;
26
+
}>(({ match, isSelected, isFollowed, currentAppName, onToggle }) => {
39
27
return (
40
-
<div className="bg-white/50 dark:bg-slate-900/50 rounded-2xl shadow-sm overflow-hidden border-2 border-cyan-500/30 dark:border-purple-500/30">
41
-
{/* Source User */}
42
-
<div className="px-4 py-3 bg-purple-100 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30">
43
-
<div className="flex justify-between gap-2 items-center">
44
-
<div className="flex-1 min-w-0">
45
-
<div className="flex flex-wrap gap-x-2 gap-y-1">
46
-
<span className="font-bold text-purple-950 dark:text-cyan-50 truncate text-base">
47
-
@{result.sourceUser.username}
48
-
</span>
28
+
<div className="flex items-start gap-3 p-3 cursor-pointer hover:scale-[1.01] transition-transform">
29
+
<AvatarWithFallback
30
+
avatar={match.avatar}
31
+
handle={match.handle || ""}
32
+
size="sm"
33
+
/>
34
+
35
+
<div className="flex-1 min-w-0 space-y-1">
36
+
<div>
37
+
{match.displayName && (
38
+
<div className="font-semibold text-purple-950 dark:text-cyan-50 leading-tight">
39
+
{match.displayName}
49
40
</div>
50
-
</div>
51
-
<div
52
-
className={`text-sm text-purple-750 dark:text-cyan-250 whitespace-nowrap flex-shrink-0`}
41
+
)}
42
+
<a
43
+
href={`https://bsky.app/profile/${match.handle}`}
44
+
target="_blank"
45
+
rel="noopener noreferrer"
46
+
className="text-sm text-purple-750 dark:text-cyan-250 hover:underline leading-tight"
53
47
>
54
-
{result.atprotoMatches.length}{" "}
55
-
{result.atprotoMatches.length === 1 ? "match" : "matches"}
56
-
</div>
48
+
@{match.handle}
49
+
</a>
57
50
</div>
58
-
</div>
59
51
60
-
{/* ATProto Matches */}
61
-
{result.atprotoMatches.length === 0 ? (
62
-
<div className="text-center py-6">
63
-
<MessageCircle className="w-8 h-8 mx-auto mb-2 opacity-50 text-purple-750 dark:text-cyan-250" />
64
-
<p className="text-sm text-purple-950 dark:text-cyan-50">
65
-
Not found on the ATmosphere yet
66
-
</p>
52
+
<div className="flex items-center flex-wrap gap-2">
53
+
{typeof match.postCount === "number" && match.postCount > 0 && (
54
+
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium">
55
+
{match.postCount.toLocaleString()} posts
56
+
</span>
57
+
)}
58
+
{typeof match.followerCount === "number" &&
59
+
match.followerCount > 0 && (
60
+
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium">
61
+
{match.followerCount.toLocaleString()} followers
62
+
</span>
63
+
)}
64
+
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium">
65
+
{match.matchScore}% match
66
+
</span>
67
67
</div>
68
-
) : (
69
-
<div className="">
70
-
{displayMatches.map((match) => {
71
-
// Check follow status for current lexicon
72
-
const isFollowedInCurrentApp =
73
-
match.followStatus?.[currentLexicon] ?? match.followed ?? false;
74
-
const isSelected = result.selectedMatches?.has(match.did);
75
68
76
-
return (
77
-
<div
78
-
key={match.did}
79
-
className="flex items-start gap-3 p-3 cursor-pointer hover:scale-[1.01] transition-transform"
80
-
>
81
-
{/* Avatar */}
82
-
<AvatarWithFallback
83
-
avatar={match.avatar}
84
-
handle={match.handle || ""}
85
-
size="sm"
86
-
/>
69
+
{match.description && (
70
+
<div className="text-sm text-purple-900 dark:text-cyan-100 line-clamp-2 pt-1">
71
+
{match.description}
72
+
</div>
73
+
)}
74
+
</div>
87
75
88
-
{/* Match Info */}
89
-
<div className="flex-1 min-w-0 space-y-1">
90
-
{/* Name and Handle */}
91
-
<div>
92
-
{match.displayName && (
93
-
<div className="font-semibold text-purple-950 dark:text-cyan-50 leading-tight">
94
-
{match.displayName}
95
-
</div>
96
-
)}
97
-
<a
98
-
href={`https://bsky.app/profile/${match.handle}`}
99
-
target="_blank"
100
-
rel="noopener noreferrer"
101
-
className="text-sm text-purple-750 dark:text-cyan-250 hover:underline leading-tight"
102
-
>
103
-
@{match.handle}
104
-
</a>
105
-
</div>
76
+
<FollowButton
77
+
isFollowed={isFollowed}
78
+
isSelected={isSelected}
79
+
onToggle={onToggle}
80
+
appName={currentAppName}
81
+
/>
82
+
</div>
83
+
);
84
+
});
106
85
107
-
{/* User Stats and Match Percent */}
108
-
<div className="flex items-center flex-wrap gap-2 sm:ml-0 -ml-10">
109
-
{typeof match.postCount === "number" &&
110
-
match.postCount > 0 && (
111
-
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium">
112
-
{match.postCount.toLocaleString()} posts
113
-
</span>
114
-
)}
115
-
{typeof match.followerCount === "number" &&
116
-
match.followerCount > 0 && (
117
-
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium">
118
-
{match.followerCount.toLocaleString()} followers
119
-
</span>
120
-
)}
121
-
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 dark:bg-slate-900 text-purple-950 dark:text-cyan-50 font-medium">
122
-
{match.matchScore}% match
123
-
</span>
124
-
</div>
86
+
MatchItem.displayName = "MatchItem";
125
87
126
-
{/* Description */}
127
-
{match.description && (
128
-
<div className="text-sm text-purple-900 dark:text-cyan-100 line-clamp-2 pt-1 sm:ml-0 -ml-10">
129
-
{match.description}
130
-
</div>
131
-
)}
132
-
</div>
88
+
const SearchResultCard = React.memo<SearchResultCardProps>(
89
+
({
90
+
result,
91
+
resultIndex,
92
+
isExpanded,
93
+
onToggleExpand,
94
+
onToggleMatchSelection,
95
+
sourcePlatform,
96
+
destinationAppId = "bluesky",
97
+
}) => {
98
+
const currentApp = useMemo(
99
+
() => getAtprotoAppWithFallback(destinationAppId),
100
+
[destinationAppId],
101
+
);
133
102
134
-
{/* Select/Follow Button */}
135
-
<button
136
-
onClick={() => onToggleMatchSelection(match.did)}
137
-
disabled={isFollowedInCurrentApp}
138
-
className={`p-2 rounded-full font-medium transition-all flex-shrink-0 self-start ${
139
-
isFollowedInCurrentApp
140
-
? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md cursor-not-allowed opacity-50"
141
-
: isSelected
142
-
? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md"
143
-
: "bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400"
144
-
}`}
145
-
title={
146
-
isFollowedInCurrentApp
147
-
? `Already following on ${currentApp?.name || "this app"}`
148
-
: isSelected
149
-
? "Selected to follow"
150
-
: "Select to follow"
151
-
}
152
-
>
153
-
{isFollowedInCurrentApp ? (
154
-
<Check className="w-4 h-4" />
155
-
) : isSelected ? (
156
-
<UserCheck className="w-4 h-4" />
157
-
) : (
158
-
<UserPlus className="w-4 h-4" />
159
-
)}
160
-
</button>
103
+
const currentLexicon = useMemo(
104
+
() => currentApp?.followLexicon || "app.bsky.graph.follow",
105
+
[currentApp],
106
+
);
107
+
108
+
const displayMatches = useMemo(
109
+
() =>
110
+
isExpanded ? result.atprotoMatches : result.atprotoMatches.slice(0, 1),
111
+
[isExpanded, result.atprotoMatches],
112
+
);
113
+
114
+
const hasMoreMatches = result.atprotoMatches.length > 1;
115
+
116
+
return (
117
+
<div className="bg-white/50 dark:bg-slate-900/50 rounded-2xl shadow-sm overflow-hidden border-2 border-cyan-500/30 dark:border-purple-500/30">
118
+
{/* Source User */}
119
+
<div className="px-4 py-3 bg-purple-100 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30">
120
+
<div className="flex justify-between gap-2 items-center">
121
+
<div className="flex-1 min-w-0">
122
+
<div className="flex flex-wrap gap-x-2 gap-y-1">
123
+
<span className="font-bold text-purple-950 dark:text-cyan-50 truncate text-base">
124
+
@{result.sourceUser.username}
125
+
</span>
161
126
</div>
162
-
);
163
-
})}
164
-
{hasMoreMatches && (
165
-
<button
166
-
onClick={onToggleExpand}
167
-
className="w-full py-2 text-sm text-purple-600 hover:text-purple-950 dark:text-cyan-400 dark:hover:text-cyan-50 font-medium transition-colors flex items-center justify-center space-x-1 border-t-2 border-cyan-500/30 dark:border-purple-500/30 hover:border-orange-500 dark:hover:border-amber-400/50"
168
-
>
169
-
<span>
170
-
{isExpanded
171
-
? "Show less"
172
-
: `Show ${result.atprotoMatches.length - 1} more ${result.atprotoMatches.length - 1 === 1 ? "option" : "options"}`}
173
-
</span>
174
-
<ChevronDown
175
-
className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
176
-
/>
177
-
</button>
178
-
)}
127
+
</div>
128
+
<div className="text-sm text-purple-750 dark:text-cyan-250 whitespace-nowrap flex-shrink-0">
129
+
{result.atprotoMatches.length}{" "}
130
+
{result.atprotoMatches.length === 1 ? "match" : "matches"}
131
+
</div>
132
+
</div>
179
133
</div>
180
-
)}
181
-
</div>
182
-
);
183
-
}
134
+
135
+
{/* ATProto Matches */}
136
+
{result.atprotoMatches.length === 0 ? (
137
+
<div className="text-center py-6">
138
+
<MessageCircle className="w-8 h-8 mx-auto mb-2 opacity-50 text-purple-750 dark:text-cyan-250" />
139
+
<p className="text-sm text-purple-950 dark:text-cyan-50">
140
+
Not found on the ATmosphere yet
141
+
</p>
142
+
</div>
143
+
) : (
144
+
<div>
145
+
{displayMatches.map((match) => {
146
+
const isFollowedInCurrentApp =
147
+
match.followStatus?.[currentLexicon] ?? match.followed ?? false;
148
+
const isSelected = result.selectedMatches?.has(match.did);
149
+
150
+
return (
151
+
<MatchItem
152
+
key={match.did}
153
+
match={match}
154
+
isSelected={isSelected || false}
155
+
isFollowed={isFollowedInCurrentApp}
156
+
currentAppName={currentApp?.name || "this app"}
157
+
onToggle={() => onToggleMatchSelection(match.did)}
158
+
/>
159
+
);
160
+
})}
161
+
{hasMoreMatches && (
162
+
<button
163
+
onClick={onToggleExpand}
164
+
className="w-full py-2 text-sm text-purple-600 hover:text-purple-950 dark:text-cyan-400 dark:hover:text-cyan-50 font-medium transition-colors flex items-center justify-center space-x-1 border-t-2 border-cyan-500/30 dark:border-purple-500/30 hover:border-orange-500 dark:hover:border-amber-400/50"
165
+
>
166
+
<span>
167
+
{isExpanded
168
+
? "Show less"
169
+
: `Show ${result.atprotoMatches.length - 1} more ${result.atprotoMatches.length - 1 === 1 ? "option" : "options"}`}
170
+
</span>
171
+
<ChevronDown
172
+
className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
173
+
/>
174
+
</button>
175
+
)}
176
+
</div>
177
+
)}
178
+
</div>
179
+
);
180
+
},
181
+
);
182
+
SearchResultCard.displayName = "SearchResultCard";
183
+
export default SearchResultCard;
+34
-25
src/components/common/AvatarWithFallback.tsx
+34
-25
src/components/common/AvatarWithFallback.tsx
···
1
+
import React, { useState } from "react";
2
+
1
3
interface AvatarWithFallbackProps {
2
4
avatar?: string;
3
5
handle: string;
···
5
7
className?: string;
6
8
}
7
9
8
-
export default function AvatarWithFallback({
9
-
avatar,
10
-
handle,
11
-
size = "md",
12
-
className = "",
13
-
}: AvatarWithFallbackProps) {
14
-
const sizeClasses = {
15
-
sm: "w-8 h-8 text-sm",
16
-
md: "w-12 h-12 text-base",
17
-
lg: "w-16 h-16 text-xl",
18
-
};
10
+
const sizeClasses = {
11
+
sm: { container: "w-8 h-8", text: "text-sm" },
12
+
md: { container: "w-12 h-12", text: "text-base" },
13
+
lg: { container: "w-16 h-16", text: "text-xl" },
14
+
};
19
15
20
-
const sizeClass = sizeClasses[size];
16
+
const AvatarWithFallback = React.memo<AvatarWithFallbackProps>(
17
+
({ avatar, handle, size = "md", className = "" }) => {
18
+
const [imageError, setImageError] = useState(false);
19
+
const { container, text } = sizeClasses[size];
20
+
21
+
const fallbackInitial = handle.charAt(0).toUpperCase();
22
+
23
+
if (!avatar || imageError) {
24
+
return (
25
+
<div
26
+
className={`${container} bg-gradient-to-br from-cyan-400 to-purple-500 rounded-full flex items-center justify-center shadow-sm ${className}`}
27
+
aria-label={`${handle}'s avatar`}
28
+
>
29
+
<span className={`text-white font-bold ${text}`}>
30
+
{fallbackInitial}
31
+
</span>
32
+
</div>
33
+
);
34
+
}
21
35
22
-
if (avatar) {
23
36
return (
24
37
<img
25
38
src={avatar}
26
39
alt={`${handle}'s avatar`}
27
-
className={`${sizeClass} rounded-full object-cover ${className}`}
40
+
className={`${container} rounded-full object-cover ${className}`}
41
+
onError={() => setImageError(true)}
42
+
loading="lazy"
28
43
/>
29
44
);
30
-
}
45
+
},
46
+
);
31
47
32
-
return (
33
-
<div
34
-
className={`${sizeClass} bg-gradient-to-br from-cyan-400 to-purple-500 rounded-full flex items-center justify-center shadow-sm ${className}`}
35
-
>
36
-
<span className="text-white font-bold">
37
-
{handle.charAt(0).toUpperCase()}
38
-
</span>
39
-
</div>
40
-
);
41
-
}
48
+
AvatarWithFallback.displayName = "AvatarWithFallback";
49
+
50
+
export default AvatarWithFallback;
+65
src/components/common/Button.tsx
+65
src/components/common/Button.tsx
···
1
+
import React from "react";
2
+
import { LucideIcon } from "lucide-react";
3
+
4
+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+
variant?: "primary" | "secondary" | "danger" | "ghost";
6
+
size?: "sm" | "md" | "lg";
7
+
isLoading?: boolean;
8
+
icon?: LucideIcon;
9
+
children: React.ReactNode;
10
+
}
11
+
12
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
13
+
(
14
+
{
15
+
variant = "primary",
16
+
size = "md",
17
+
isLoading = false,
18
+
icon: Icon,
19
+
children,
20
+
className = "",
21
+
disabled,
22
+
...props
23
+
},
24
+
ref,
25
+
) => {
26
+
const baseStyles =
27
+
"inline-flex items-center justify-center font-semibold transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
28
+
29
+
const variants = {
30
+
primary:
31
+
"bg-firefly-banner dark:bg-firefly-banner-dark text-white hover:shadow-lg focus:ring-orange-500 dark:focus:ring-amber-400",
32
+
secondary:
33
+
"bg-slate-600 dark:bg-slate-700 hover:bg-slate-700 dark:hover:bg-slate-600 text-white focus:ring-slate-400",
34
+
danger: "bg-red-600 hover:bg-red-700 text-white focus:ring-red-500",
35
+
ghost:
36
+
"bg-transparent hover:bg-purple-100 dark:hover:bg-slate-800 text-purple-900 dark:text-cyan-100 focus:ring-purple-500",
37
+
};
38
+
39
+
const sizes = {
40
+
sm: "px-3 py-1.5 text-sm rounded-lg",
41
+
md: "px-4 py-2 text-base rounded-xl",
42
+
lg: "px-6 py-3 text-lg rounded-xl",
43
+
};
44
+
45
+
return (
46
+
<button
47
+
ref={ref}
48
+
disabled={disabled || isLoading}
49
+
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
50
+
{...props}
51
+
>
52
+
{isLoading ? (
53
+
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
54
+
) : Icon ? (
55
+
<Icon className="w-5 h-5 mr-2" />
56
+
) : null}
57
+
{children}
58
+
</button>
59
+
);
60
+
},
61
+
);
62
+
63
+
Button.displayName = "Button";
64
+
65
+
export default Button;
+51
src/components/common/FollowButton.tsx
+51
src/components/common/FollowButton.tsx
···
1
+
import React from "react";
2
+
import { Check, UserPlus, UserCheck } from "lucide-react";
3
+
4
+
interface FollowButtonProps {
5
+
isFollowed: boolean;
6
+
isSelected: boolean;
7
+
onToggle: () => void;
8
+
disabled?: boolean;
9
+
appName?: string;
10
+
}
11
+
12
+
const FollowButton = React.memo<FollowButtonProps>(
13
+
({ isFollowed, isSelected, onToggle, disabled = false, appName }) => {
14
+
const getButtonStyles = () => {
15
+
if (isFollowed) {
16
+
return "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md cursor-not-allowed opacity-50";
17
+
}
18
+
if (isSelected) {
19
+
return "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md hover:scale-105";
20
+
}
21
+
return "bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400 hover:scale-105";
22
+
};
23
+
24
+
const getTitle = () => {
25
+
if (isFollowed) {
26
+
return appName
27
+
? `Already following on ${appName}`
28
+
: "Already following";
29
+
}
30
+
return isSelected ? "Selected to follow" : "Select to follow";
31
+
};
32
+
33
+
const Icon = isFollowed ? Check : isSelected ? UserCheck : UserPlus;
34
+
35
+
return (
36
+
<button
37
+
onClick={onToggle}
38
+
disabled={disabled || isFollowed}
39
+
className={`p-2 rounded-full font-medium transition-all flex-shrink-0 self-start ${getButtonStyles()}`}
40
+
title={getTitle()}
41
+
aria-label={getTitle()}
42
+
>
43
+
<Icon className="w-4 h-4" />
44
+
</button>
45
+
);
46
+
},
47
+
);
48
+
49
+
FollowButton.displayName = "FollowButton";
50
+
51
+
export default FollowButton;
+63
src/components/common/IconButton.tsx
+63
src/components/common/IconButton.tsx
···
1
+
import React from "react";
2
+
import { LucideIcon } from "lucide-react";
3
+
4
+
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+
icon: LucideIcon;
6
+
label: string;
7
+
variant?: "primary" | "secondary" | "ghost";
8
+
size?: "sm" | "md" | "lg";
9
+
}
10
+
11
+
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
12
+
(
13
+
{
14
+
icon: Icon,
15
+
label,
16
+
variant = "ghost",
17
+
size = "md",
18
+
className = "",
19
+
...props
20
+
},
21
+
ref,
22
+
) => {
23
+
const baseStyles =
24
+
"inline-flex items-center justify-center rounded-full transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
25
+
26
+
const variants = {
27
+
primary:
28
+
"bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 hover:border-orange-500 dark:hover:border-amber-400",
29
+
secondary:
30
+
"bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400",
31
+
ghost:
32
+
"bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:bg-white dark:hover:bg-slate-700",
33
+
};
34
+
35
+
const sizes = {
36
+
sm: "p-1.5",
37
+
md: "p-2",
38
+
lg: "p-3",
39
+
};
40
+
41
+
const iconSizes = {
42
+
sm: "w-4 h-4",
43
+
md: "w-5 h-5",
44
+
lg: "w-6 h-6",
45
+
};
46
+
47
+
return (
48
+
<button
49
+
ref={ref}
50
+
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
51
+
aria-label={label}
52
+
title={label}
53
+
{...props}
54
+
>
55
+
<Icon className={iconSizes[size]} />
56
+
</button>
57
+
);
58
+
},
59
+
);
60
+
61
+
IconButton.displayName = "IconButton";
62
+
63
+
export default IconButton;
+63
src/components/common/Notification.tsx
+63
src/components/common/Notification.tsx
···
1
+
import React, { useEffect } from "react";
2
+
import { X, AlertCircle, CheckCircle, Info, AlertTriangle } from "lucide-react";
3
+
4
+
export type NotificationType = "success" | "error" | "info" | "warning";
5
+
6
+
interface NotificationProps {
7
+
type: NotificationType;
8
+
message: string;
9
+
onClose: () => void;
10
+
duration?: number;
11
+
}
12
+
13
+
const iconMap = {
14
+
success: CheckCircle,
15
+
error: AlertCircle,
16
+
warning: AlertTriangle,
17
+
info: Info,
18
+
};
19
+
20
+
const styleMap = {
21
+
success:
22
+
"bg-green-50 dark:bg-green-900/20 border-green-500 text-green-900 dark:text-green-100",
23
+
error:
24
+
"bg-red-50 dark:bg-red-900/20 border-red-500 text-red-900 dark:text-red-100",
25
+
warning:
26
+
"bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500 text-yellow-900 dark:text-yellow-100",
27
+
info: "bg-blue-50 dark:bg-blue-900/20 border-blue-500 text-blue-900 dark:text-blue-100",
28
+
};
29
+
30
+
const Notification: React.FC<NotificationProps> = ({
31
+
type,
32
+
message,
33
+
onClose,
34
+
duration = 5000,
35
+
}) => {
36
+
const Icon = iconMap[type];
37
+
38
+
useEffect(() => {
39
+
if (duration > 0) {
40
+
const timer = setTimeout(onClose, duration);
41
+
return () => clearTimeout(timer);
42
+
}
43
+
}, [duration, onClose]);
44
+
45
+
return (
46
+
<div
47
+
className={`flex items-start gap-3 p-4 rounded-xl border-2 shadow-lg ${styleMap[type]} animate-slide-in`}
48
+
role="alert"
49
+
>
50
+
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
51
+
<p className="flex-1 text-sm font-medium">{message}</p>
52
+
<button
53
+
onClick={onClose}
54
+
className="flex-shrink-0 hover:opacity-70 transition-opacity"
55
+
aria-label="Close notification"
56
+
>
57
+
<X className="w-5 h-5" />
58
+
</button>
59
+
</div>
60
+
);
61
+
};
62
+
63
+
export default Notification;
+41
src/components/common/NotificationContainer.tsx
+41
src/components/common/NotificationContainer.tsx
···
1
+
import React from "react";
2
+
import { createPortal } from "react-dom";
3
+
import Notification, { NotificationType } from "./Notification";
4
+
5
+
export interface NotificationItem {
6
+
id: string;
7
+
type: NotificationType;
8
+
message: string;
9
+
}
10
+
11
+
interface NotificationContainerProps {
12
+
notifications: NotificationItem[];
13
+
onRemove: (id: string) => void;
14
+
}
15
+
16
+
const NotificationContainer: React.FC<NotificationContainerProps> = ({
17
+
notifications,
18
+
onRemove,
19
+
}) => {
20
+
if (notifications.length === 0) return null;
21
+
22
+
return createPortal(
23
+
<div
24
+
className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-md"
25
+
aria-live="polite"
26
+
aria-atomic="false"
27
+
>
28
+
{notifications.map((notification) => (
29
+
<Notification
30
+
key={notification.id}
31
+
type={notification.type}
32
+
message={notification.message}
33
+
onClose={() => onRemove(notification.id)}
34
+
/>
35
+
))}
36
+
</div>,
37
+
document.body,
38
+
);
39
+
};
40
+
41
+
export default NotificationContainer;
+1
-3
src/hooks/useAuth.ts
+1
-3
src/hooks/useAuth.ts
···
53
53
54
54
async function login(handle: string) {
55
55
if (!handle) {
56
-
const errorMsg = "Please enter your handle";
57
-
setStatusMessage(errorMsg);
58
-
throw new Error(errorMsg);
56
+
throw new Error("Please enter your handle");
59
57
}
60
58
61
59
setStatusMessage("Starting authentication...");
+61
src/hooks/useNotifications.ts
+61
src/hooks/useNotifications.ts
···
1
+
import { useState, useCallback } from "react";
2
+
import type { NotificationType } from "../components/common/Notification";
3
+
4
+
export interface NotificationItem {
5
+
id: string;
6
+
type: NotificationType;
7
+
message: string;
8
+
}
9
+
10
+
export function useNotifications() {
11
+
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
12
+
13
+
const addNotification = useCallback(
14
+
(type: NotificationType, message: string) => {
15
+
const id = `notification-${Date.now()}-${Math.random()}`;
16
+
setNotifications((prev) => [...prev, { id, type, message }]);
17
+
return id;
18
+
},
19
+
[],
20
+
);
21
+
22
+
const removeNotification = useCallback((id: string) => {
23
+
setNotifications((prev) => prev.filter((n) => n.id !== id));
24
+
}, []);
25
+
26
+
const clearAll = useCallback(() => {
27
+
setNotifications([]);
28
+
}, []);
29
+
30
+
// Convenience methods
31
+
const success = useCallback(
32
+
(message: string) => addNotification("success", message),
33
+
[addNotification],
34
+
);
35
+
36
+
const error = useCallback(
37
+
(message: string) => addNotification("error", message),
38
+
[addNotification],
39
+
);
40
+
41
+
const info = useCallback(
42
+
(message: string) => addNotification("info", message),
43
+
[addNotification],
44
+
);
45
+
46
+
const warning = useCallback(
47
+
(message: string) => addNotification("warning", message),
48
+
[addNotification],
49
+
);
50
+
51
+
return {
52
+
notifications,
53
+
addNotification,
54
+
removeNotification,
55
+
clearAll,
56
+
success,
57
+
error,
58
+
info,
59
+
warning,
60
+
};
61
+
}
+16
src/index.css
+16
src/index.css
···
171
171
--color-hover: rgb(126 34 206 / 0.2);
172
172
--color-avatar-fallback: rgb(126 34 206 / 0.2);
173
173
}
174
+
175
+
/* Notification animations */
176
+
@keyframes slide-in {
177
+
from {
178
+
transform: translateX(100%);
179
+
opacity: 0;
180
+
}
181
+
to {
182
+
transform: translateX(0);
183
+
opacity: 1;
184
+
}
185
+
}
186
+
187
+
.animate-slide-in {
188
+
animation: slide-in 0.3s ease-out;
189
+
}
+61
-18
src/lib/api/adapters/RealApiAdapter.ts
+61
-18
src/lib/api/adapters/RealApiAdapter.ts
···
6
6
SaveResultsResponse,
7
7
SearchResult,
8
8
} from "../../../types";
9
-
import { CacheService } from "../../../lib/utils/cache";
9
+
import { CacheService } from "../../utils/cache";
10
10
import { CACHE_CONFIG } from "../../../config/constants";
11
11
12
12
/**
···
20
20
}
21
21
22
22
/**
23
+
* Generate cache key for complex requests
24
+
*/
25
+
function generateCacheKey(
26
+
prefix: string,
27
+
...parts: (string | number)[]
28
+
): string {
29
+
return `${prefix}:${parts.join(":")}`;
30
+
}
31
+
32
+
/**
23
33
* Real API Client Adapter
24
-
* Implements actual HTTP calls to backend
34
+
* Implements actual HTTP calls to backend with optimized caching
25
35
*/
26
36
export class RealApiAdapter implements IApiClient {
27
37
private responseCache = new CacheService(CACHE_CONFIG.DEFAULT_TTL);
···
121
131
results: SearchResult[];
122
132
pagination?: any;
123
133
}> {
124
-
const cacheKey = `upload-details-${uploadId}-p${page}-s${pageSize}`;
134
+
const cacheKey = generateCacheKey(
135
+
"upload-details",
136
+
uploadId,
137
+
page,
138
+
pageSize,
139
+
);
125
140
const cached = this.responseCache.get<any>(cacheKey);
126
141
if (cached) {
127
142
return cached;
···
146
161
async getAllUploadDetails(
147
162
uploadId: string,
148
163
): Promise<{ results: SearchResult[] }> {
149
-
const firstPage = await this.getUploadDetails(uploadId, 1, 100);
164
+
// Check if we have all pages cached
165
+
const firstPageKey = generateCacheKey("upload-details", uploadId, 1, 100);
166
+
const firstPage = this.responseCache.get<any>(firstPageKey);
150
167
151
-
if (!firstPage.pagination || firstPage.pagination.totalPages === 1) {
168
+
if (
169
+
firstPage &&
170
+
(!firstPage.pagination || firstPage.pagination.totalPages === 1)
171
+
) {
152
172
return { results: firstPage.results };
153
173
}
154
174
155
-
const allResults = [...firstPage.results];
156
-
const promises = [];
175
+
// Fetch first page to get total pages
176
+
const firstPageData = await this.getUploadDetails(uploadId, 1, 100);
177
+
178
+
if (
179
+
!firstPageData.pagination ||
180
+
firstPageData.pagination.totalPages === 1
181
+
) {
182
+
return { results: firstPageData.results };
183
+
}
184
+
185
+
// Fetch remaining pages in parallel
186
+
const allResults = [...firstPageData.results];
187
+
const pagePromises = [];
157
188
158
-
for (let page = 2; page <= firstPage.pagination.totalPages; page++) {
159
-
promises.push(this.getUploadDetails(uploadId, page, 100));
189
+
for (let page = 2; page <= firstPageData.pagination.totalPages; page++) {
190
+
pagePromises.push(this.getUploadDetails(uploadId, page, 100));
160
191
}
161
192
162
-
const remainingPages = await Promise.all(promises);
193
+
const remainingPages = await Promise.all(pagePromises);
163
194
for (const pageData of remainingPages) {
164
195
allResults.push(...pageData.results);
165
196
}
···
171
202
dids: string[],
172
203
followLexicon: string,
173
204
): Promise<Record<string, boolean>> {
174
-
const cacheKey = `follow-status-${followLexicon}-${dids.slice().sort().join(",")}`;
205
+
// Sort DIDs for consistent cache key
206
+
const sortedDids = [...dids].sort();
207
+
const cacheKey = generateCacheKey(
208
+
"follow-status",
209
+
followLexicon,
210
+
sortedDids.join(","),
211
+
);
175
212
const cached = this.responseCache.get<Record<string, boolean>>(cacheKey);
176
213
if (cached) {
177
214
return cached;
···
205
242
usernames: string[],
206
243
followLexicon?: string,
207
244
): Promise<{ results: BatchSearchResult[] }> {
208
-
const cacheKey = `search-${followLexicon || "default"}-${usernames.slice().sort().join(",")}`;
245
+
// Sort usernames for consistent cache key
246
+
const sortedUsernames = [...usernames].sort();
247
+
const cacheKey = generateCacheKey(
248
+
"search",
249
+
followLexicon || "default",
250
+
sortedUsernames.join(","),
251
+
);
209
252
const cached = this.responseCache.get<any>(cacheKey);
210
253
if (cached) {
211
254
return cached;
···
254
297
const response = await res.json();
255
298
const data = unwrapResponse<any>(response);
256
299
257
-
// Invalidate caches after following
258
-
this.responseCache.invalidate("uploads");
259
-
this.responseCache.invalidatePattern("upload-details");
260
-
this.responseCache.invalidatePattern("follow-status");
300
+
// Invalidate relevant caches after following
301
+
this.cache.invalidate("uploads");
302
+
this.cache.invalidatePattern("upload-details");
303
+
this.cache.invalidatePattern("follow-status");
261
304
262
305
return data;
263
306
}
···
291
334
const data = unwrapResponse<SaveResultsResponse>(response);
292
335
293
336
// Invalidate caches
294
-
this.responseCache.invalidate("uploads");
295
-
this.responseCache.invalidatePattern("upload-details");
337
+
this.cache.invalidate("uploads");
338
+
this.cache.invalidatePattern("upload-details");
296
339
297
340
return data;
298
341
} else {
+2
src/lib/utils/index.ts
+2
src/lib/utils/index.ts
+51
-41
src/lib/utils/platform.ts
+51
-41
src/lib/utils/platform.ts
···
2
2
import { ATPROTO_APPS, type AtprotoApp } from "../../config/atprotoApps";
3
3
import type { AtprotoAppId } from "../../types/settings";
4
4
5
+
// Cache for platform lookups
6
+
const platformCache = new Map<string, PlatformConfig>();
7
+
const appCache = new Map<AtprotoAppId, AtprotoApp>();
8
+
5
9
/**
6
-
* Get platform configuration by key
7
-
*
8
-
* @param platformKey - The platform identifier (e.g., "tiktok", "instagram")
9
-
* @returns Platform configuration or default to TikTok
10
-
**/
10
+
* Get platform configuration by key (memoized)
11
+
*/
11
12
export function getPlatform(platformKey: string): PlatformConfig {
12
-
return PLATFORMS[platformKey] || PLATFORMS.tiktok;
13
+
if (!platformCache.has(platformKey)) {
14
+
platformCache.set(platformKey, PLATFORMS[platformKey] || PLATFORMS.tiktok);
15
+
}
16
+
return platformCache.get(platformKey)!;
13
17
}
14
18
15
19
/**
16
20
* Get platform gradient color classes for UI
17
-
*
18
-
* @param platformKey - The platform identifier
19
-
* @returns Tailwind gradient classes for the platform
20
-
**/
21
+
*/
21
22
export function getPlatformColor(platformKey: string): string {
22
23
const colors: Record<string, string> = {
23
24
tiktok: "from-black via-gray-800 to-cyan-400",
···
31
32
}
32
33
33
34
/**
34
-
* Get ATProto app configuration by ID
35
-
*
36
-
* @param appId - The app identifier
37
-
* @returns App configuration or undefined if not found
38
-
**/
35
+
* Get ATProto app configuration by ID (memoized)
36
+
*/
39
37
export function getAtprotoApp(appId: AtprotoAppId): AtprotoApp | undefined {
40
-
return ATPROTO_APPS[appId];
38
+
if (!appCache.has(appId)) {
39
+
const app = ATPROTO_APPS[appId];
40
+
if (app) {
41
+
appCache.set(appId, app);
42
+
}
43
+
}
44
+
return appCache.get(appId);
41
45
}
42
46
43
47
/**
44
-
* Get ATProto app with fallback to default
45
-
*
46
-
* @param appId - The app identifier
47
-
* @param defaultApp - Default app ID to use as fallback
48
-
* @returns App configuration, falling back to default or Bluesky
49
-
**/
48
+
* Get ATProto app with fallback to default (memoized)
49
+
*/
50
50
export function getAtprotoAppWithFallback(
51
51
appId: AtprotoAppId,
52
52
defaultApp: AtprotoAppId = "bluesky",
53
53
): AtprotoApp {
54
54
return (
55
-
ATPROTO_APPS[appId] || ATPROTO_APPS[defaultApp] || ATPROTO_APPS.bluesky
55
+
getAtprotoApp(appId) || getAtprotoApp(defaultApp) || ATPROTO_APPS.bluesky
56
56
);
57
57
}
58
58
59
59
/**
60
-
* Get all enabled ATProto apps
61
-
*
62
-
* @returns Array of enabled app configurations
63
-
**/
60
+
* Get all enabled ATProto apps (cached result)
61
+
*/
62
+
let enabledAppsCache: AtprotoApp[] | null = null;
64
63
export function getEnabledAtprotoApps(): AtprotoApp[] {
65
-
return Object.values(ATPROTO_APPS).filter((app) => app.enabled);
64
+
if (!enabledAppsCache) {
65
+
enabledAppsCache = Object.values(ATPROTO_APPS).filter((app) => app.enabled);
66
+
}
67
+
return enabledAppsCache;
66
68
}
67
69
68
70
/**
69
-
* Get all enabled platforms
70
-
*
71
-
* @returns Array of [key, config] tuples for enabled platforms
72
-
**/
71
+
* Get all enabled platforms (cached result)
72
+
*/
73
+
let enabledPlatformsCache: Array<[string, PlatformConfig]> | null = null;
73
74
export function getEnabledPlatforms(): Array<[string, PlatformConfig]> {
74
-
return Object.entries(PLATFORMS).filter(([_, config]) => config.enabled);
75
+
if (!enabledPlatformsCache) {
76
+
enabledPlatformsCache = Object.entries(PLATFORMS).filter(
77
+
([_, config]) => config.enabled,
78
+
);
79
+
}
80
+
return enabledPlatformsCache;
75
81
}
76
82
77
83
/**
78
84
* Check if a platform is enabled
79
-
*
80
-
* @param platformKey - The platform identifier
81
-
* @returns True if platform is enabled
82
-
**/
85
+
*/
83
86
export function isPlatformEnabled(platformKey: string): boolean {
84
87
return PLATFORMS[platformKey]?.enabled || false;
85
88
}
86
89
87
90
/**
88
91
* Check if an app is enabled
89
-
*
90
-
* @param appId - The app identifier
91
-
* @returns True if app is enabled
92
-
**/
92
+
*/
93
93
export function isAppEnabled(appId: AtprotoAppId): boolean {
94
94
return ATPROTO_APPS[appId]?.enabled || false;
95
95
}
96
+
97
+
/**
98
+
* Clear all caches (useful for hot reload in development)
99
+
*/
100
+
export function clearPlatformCaches(): void {
101
+
platformCache.clear();
102
+
appCache.clear();
103
+
enabledAppsCache = null;
104
+
enabledPlatformsCache = null;
105
+
}
+26
-4
src/types/index.ts
+26
-4
src/types/index.ts
···
1
-
export * from "./auth.types";
2
-
export * from "./search.types";
3
-
export * from "./common.types";
1
+
// Core type exports
2
+
export type {
3
+
AtprotoSession,
4
+
UserSessionData,
5
+
OAuthConfig,
6
+
StateData,
7
+
SessionData,
8
+
} from "./auth.types";
4
9
5
-
// Re-export settings types for convenience
10
+
export type {
11
+
SourceUser,
12
+
AtprotoMatch,
13
+
SearchResult,
14
+
SearchProgress,
15
+
BatchSearchResult,
16
+
BatchFollowResult,
17
+
} from "./search.types";
18
+
19
+
export type { AppStep, Upload, SaveResultsResponse } from "./common.types";
20
+
6
21
export type {
7
22
UserSettings,
8
23
PlatformDestinations,
9
24
AtprotoApp,
10
25
AtprotoAppId,
11
26
} from "./settings";
27
+
28
+
// Re-export for convenience
29
+
export * from "./auth.types";
30
+
export * from "./search.types";
31
+
export * from "./common.types";
32
+
export * from "./settings";
33
+
export * from "./ui.types";
+30
src/types/ui.types.ts
+30
src/types/ui.types.ts
···
1
+
import type { LucideIcon } from "lucide-react";
2
+
3
+
export interface BaseComponentProps {
4
+
className?: string;
5
+
}
6
+
7
+
export interface AvatarProps extends BaseComponentProps {
8
+
avatar?: string;
9
+
handle: string;
10
+
size?: "sm" | "md" | "lg";
11
+
}
12
+
13
+
export interface ButtonVariant {
14
+
variant?: "primary" | "secondary" | "danger" | "ghost";
15
+
size?: "sm" | "md" | "lg";
16
+
isLoading?: boolean;
17
+
disabled?: boolean;
18
+
}
19
+
20
+
export interface IconButtonProps extends ButtonVariant, BaseComponentProps {
21
+
icon: LucideIcon;
22
+
label: string;
23
+
onClick?: () => void;
24
+
}
25
+
26
+
export interface TabConfig {
27
+
id: string;
28
+
icon: LucideIcon;
29
+
label: string;
30
+
}