1import { useState, useEffect, useRef } from "react";
2import { Link } from "react-router-dom";
3import { useAuth } from "../context/AuthContext";
4import { searchActors, startLogin } from "../api/client";
5import { AtSign } from "lucide-react";
6import logo from "../assets/logo.svg";
7
8export default function Login() {
9 const { isAuthenticated, user, logout } = useAuth();
10 const [handle, setHandle] = useState("");
11 const [inviteCode, setInviteCode] = useState("");
12 const [showInviteInput, setShowInviteInput] = useState(false);
13 const [suggestions, setSuggestions] = useState([]);
14 const [showSuggestions, setShowSuggestions] = useState(false);
15 const [loading, setLoading] = useState(false);
16 const [error, setError] = useState(null);
17 const [selectedIndex, setSelectedIndex] = useState(-1);
18 const inputRef = useRef(null);
19 const inviteRef = useRef(null);
20 const suggestionsRef = useRef(null);
21
22 const [providerIndex, setProviderIndex] = useState(0);
23 const [morphClass, setMorphClass] = useState("morph-in");
24 const providers = [
25 "AT Protocol",
26 "Bluesky",
27 "Blacksky",
28 "Tangled",
29 "selfhosted.social",
30 "Northsky",
31 "witchcraft.systems",
32 "topphie.social",
33 "altq.net",
34 ];
35
36 useEffect(() => {
37 const cycleText = () => {
38 setMorphClass("morph-out");
39
40 setTimeout(() => {
41 setProviderIndex((prev) => (prev + 1) % providers.length);
42 setMorphClass("morph-in");
43 }, 400);
44 };
45
46 const interval = setInterval(cycleText, 3000);
47 return () => clearInterval(interval);
48 }, [providers.length]);
49
50 const isSelectionRef = useRef(false);
51
52 useEffect(() => {
53 if (handle.length >= 3) {
54 if (isSelectionRef.current) {
55 isSelectionRef.current = false;
56 return;
57 }
58
59 const timer = setTimeout(async () => {
60 try {
61 const data = await searchActors(handle);
62 setSuggestions(data.actors || []);
63 setShowSuggestions(true);
64 setSelectedIndex(-1);
65 } catch (e) {
66 console.error("Search failed:", e);
67 }
68 }, 300);
69 return () => clearTimeout(timer);
70 }
71 }, [handle]);
72
73 useEffect(() => {
74 const handleClickOutside = (e) => {
75 if (
76 suggestionsRef.current &&
77 !suggestionsRef.current.contains(e.target) &&
78 inputRef.current &&
79 !inputRef.current.contains(e.target)
80 ) {
81 setShowSuggestions(false);
82 }
83 };
84 document.addEventListener("mousedown", handleClickOutside);
85 return () => document.removeEventListener("mousedown", handleClickOutside);
86 }, []);
87
88 if (isAuthenticated) {
89 return (
90 <div className="login-page">
91 <div className="login-avatar-large">
92 {user?.avatar ? (
93 <img src={user.avatar} alt={user.displayName || user.handle} />
94 ) : (
95 <span>
96 {(user?.displayName || user?.handle || "??")
97 .substring(0, 2)
98 .toUpperCase()}
99 </span>
100 )}
101 </div>
102 <h1 className="login-welcome">
103 Welcome back, {user?.displayName || user?.handle}
104 </h1>
105 <div className="login-actions">
106 <Link to={`/profile/${user?.did}`} className="btn btn-primary">
107 View Profile
108 </Link>
109 <button onClick={logout} className="btn btn-ghost">
110 Sign out
111 </button>
112 </div>
113 </div>
114 );
115 }
116
117 const handleKeyDown = (e) => {
118 if (!showSuggestions || suggestions.length === 0) return;
119
120 if (e.key === "ArrowDown") {
121 e.preventDefault();
122 setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
123 } else if (e.key === "ArrowUp") {
124 e.preventDefault();
125 setSelectedIndex((prev) => Math.max(prev - 1, -1));
126 } else if (e.key === "Enter" && selectedIndex >= 0) {
127 e.preventDefault();
128 selectSuggestion(suggestions[selectedIndex]);
129 } else if (e.key === "Escape") {
130 setShowSuggestions(false);
131 }
132 };
133
134 const selectSuggestion = (actor) => {
135 isSelectionRef.current = true;
136 setHandle(actor.handle);
137 setSuggestions([]);
138 setShowSuggestions(false);
139 inputRef.current?.blur();
140 };
141
142 const handleSubmit = async (e) => {
143 e.preventDefault();
144 if (!handle.trim()) return;
145 if (showInviteInput && !inviteCode.trim()) return;
146
147 setLoading(true);
148 setError(null);
149
150 try {
151 const result = await startLogin(handle.trim(), inviteCode.trim());
152 if (result.authorizationUrl) {
153 window.location.href = result.authorizationUrl;
154 }
155 } catch (err) {
156 console.error("Login error:", err);
157 if (
158 err.message &&
159 (err.message.includes("invite_required") ||
160 err.message.includes("Invite code required"))
161 ) {
162 setShowInviteInput(true);
163 setError("Please enter an invite code to continue.");
164 setTimeout(() => inviteRef.current?.focus(), 100);
165 } else {
166 setError(err.message || "Failed to start login");
167 }
168 setLoading(false);
169 }
170 };
171
172 return (
173 <div className="login-page">
174 <div className="login-header-group">
175 <img src={logo} alt="Margin Logo" className="login-logo-img" />
176 <span className="login-x">X</span>
177 <div className="login-atproto-icon">
178 <AtSign size={64} strokeWidth={2.4} />
179 </div>
180 </div>
181
182 <h1 className="login-heading">
183 Sign in with your{" "}
184 <span className={`morph-container ${morphClass}`}>
185 {providers[providerIndex]}
186 </span>{" "}
187 handle
188 </h1>
189
190 <form onSubmit={handleSubmit} className="login-form">
191 <div className="login-input-wrapper">
192 <input
193 ref={inputRef}
194 type="text"
195 className="login-input"
196 placeholder="yourname.bsky.social"
197 value={handle}
198 onChange={(e) => {
199 const val = e.target.value;
200 setHandle(val);
201 if (val.length < 3) {
202 setSuggestions([]);
203 setShowSuggestions(false);
204 }
205 }}
206 onKeyDown={handleKeyDown}
207 onFocus={() =>
208 handle.length >= 3 &&
209 suggestions.length > 0 &&
210 !handle.includes(".") &&
211 setShowSuggestions(true)
212 }
213 autoComplete="off"
214 autoCapitalize="off"
215 autoCorrect="off"
216 spellCheck="false"
217 disabled={loading}
218 />
219
220 {showSuggestions && suggestions.length > 0 && (
221 <div className="login-suggestions" ref={suggestionsRef}>
222 {suggestions.map((actor, index) => (
223 <button
224 key={actor.did}
225 type="button"
226 className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`}
227 onClick={() => selectSuggestion(actor)}
228 >
229 <div className="login-suggestion-avatar">
230 {actor.avatar ? (
231 <img src={actor.avatar} alt="" />
232 ) : (
233 <span>
234 {(actor.displayName || actor.handle)
235 .substring(0, 2)
236 .toUpperCase()}
237 </span>
238 )}
239 </div>
240 <div className="login-suggestion-info">
241 <span className="login-suggestion-name">
242 {actor.displayName || actor.handle}
243 </span>
244 <span className="login-suggestion-handle">
245 @{actor.handle}
246 </span>
247 </div>
248 </button>
249 ))}
250 </div>
251 )}
252 </div>
253
254 {showInviteInput && (
255 <div
256 className="login-input-wrapper"
257 style={{ marginTop: "12px", animation: "fadeIn 0.3s ease" }}
258 >
259 <input
260 ref={inviteRef}
261 type="text"
262 className="login-input"
263 placeholder="Enter invite code"
264 value={inviteCode}
265 onChange={(e) => setInviteCode(e.target.value)}
266 autoComplete="off"
267 disabled={loading}
268 style={{ borderColor: "var(--accent)" }}
269 />
270 </div>
271 )}
272
273 {error && <p className="login-error">{error}</p>}
274
275 <button
276 type="submit"
277 className="btn btn-primary login-submit"
278 disabled={
279 loading || !handle.trim() || (showInviteInput && !inviteCode.trim())
280 }
281 >
282 {loading
283 ? "Connecting..."
284 : showInviteInput
285 ? "Submit Code"
286 : "Continue"}
287 </button>
288
289 <p className="login-legal">
290 By signing in, you agree to our{" "}
291 <Link to="/terms">Terms of Service</Link> and{" "}
292 <Link to="/privacy">Privacy Policy</Link>.
293 </p>
294 </form>
295 </div>
296 );
297}