Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 9.1 kB view raw
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}