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