Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 312 lines 9.6 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"; 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}