Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
111
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v0.1.11 267 lines 8.3 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 { HelpCircle } 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 [showHelp, setShowHelp] = useState(false); 16 const [loading, setLoading] = useState(false); 17 const [error, setError] = useState(null); 18 const [selectedIndex, setSelectedIndex] = useState(-1); 19 const inputRef = useRef(null); 20 const inviteRef = useRef(null); 21 const suggestionsRef = useRef(null); 22 23 const isSelectionRef = useRef(false); 24 25 useEffect(() => { 26 if (handle.length < 3) { 27 setSuggestions([]); 28 setShowSuggestions(false); 29 return; 30 } 31 32 if (isSelectionRef.current) { 33 isSelectionRef.current = false; 34 return; 35 } 36 37 const timer = setTimeout(async () => { 38 try { 39 const data = await searchActors(handle); 40 setSuggestions(data.actors || []); 41 setShowSuggestions(true); 42 setSelectedIndex(-1); 43 } catch (e) { 44 console.error("Search failed:", e); 45 } 46 }, 300); 47 48 return () => clearTimeout(timer); 49 }, [handle]); 50 51 useEffect(() => { 52 const handleClickOutside = (e) => { 53 if ( 54 suggestionsRef.current && 55 !suggestionsRef.current.contains(e.target) && 56 inputRef.current && 57 !inputRef.current.contains(e.target) 58 ) { 59 setShowSuggestions(false); 60 } 61 }; 62 document.addEventListener("mousedown", handleClickOutside); 63 return () => document.removeEventListener("mousedown", handleClickOutside); 64 }, []); 65 66 const handleKeyDown = (e) => { 67 if (!showSuggestions || suggestions.length === 0) return; 68 69 if (e.key === "ArrowDown") { 70 e.preventDefault(); 71 setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 72 } else if (e.key === "ArrowUp") { 73 e.preventDefault(); 74 setSelectedIndex((prev) => Math.max(prev - 1, -1)); 75 } else if (e.key === "Enter" && selectedIndex >= 0) { 76 e.preventDefault(); 77 selectSuggestion(suggestions[selectedIndex]); 78 } else if (e.key === "Escape") { 79 setShowSuggestions(false); 80 } 81 }; 82 83 const selectSuggestion = (actor) => { 84 isSelectionRef.current = true; 85 setHandle(actor.handle); 86 setSuggestions([]); 87 setShowSuggestions(false); 88 inputRef.current?.blur(); 89 }; 90 91 const handleSubmit = async (e) => { 92 e.preventDefault(); 93 if (!handle.trim()) return; 94 if (showInviteInput && !inviteCode.trim()) return; 95 96 setLoading(true); 97 setError(null); 98 99 try { 100 const result = await startLogin(handle.trim(), inviteCode.trim()); 101 if (result.authorizationUrl) { 102 window.location.href = result.authorizationUrl; 103 } 104 } catch (err) { 105 console.error("Login error:", err); 106 if ( 107 err.message && 108 (err.message.includes("invite_required") || 109 err.message.includes("Invite code required")) 110 ) { 111 setShowInviteInput(true); 112 setError("Please enter an invite code to continue."); 113 setTimeout(() => inviteRef.current?.focus(), 100); 114 } else { 115 setError(err.message || "Failed to start login"); 116 } 117 setLoading(false); 118 } 119 }; 120 121 if (isAuthenticated) { 122 return ( 123 <div className="login-page"> 124 <div className="login-avatar-large"> 125 {user?.avatar ? ( 126 <img src={user.avatar} alt={user.displayName || user.handle} /> 127 ) : ( 128 <span> 129 {(user?.displayName || user?.handle || "??") 130 .substring(0, 2) 131 .toUpperCase()} 132 </span> 133 )} 134 </div> 135 <h1 className="login-welcome"> 136 Welcome back, {user?.displayName || user?.handle} 137 </h1> 138 <div className="login-actions"> 139 <Link to={`/profile/${user?.did}`} className="btn btn-primary"> 140 View Profile 141 </Link> 142 <button onClick={logout} className="btn btn-ghost"> 143 Sign out 144 </button> 145 </div> 146 </div> 147 ); 148 } 149 150 return ( 151 <div className="login-page"> 152 <img src={logo} alt="Margin Logo" className="login-logo-img" /> 153 154 <h1 className="login-heading"> 155 Use the AT Protocol to login to Margin 156 <button 157 className="login-help-btn" 158 onClick={() => setShowHelp(!showHelp)} 159 type="button" 160 > 161 <HelpCircle size={20} /> 162 </button> 163 </h1> 164 165 {showHelp && ( 166 <p className="login-help-text"> 167 The AT Protocol is an open, decentralized network for social apps. 168 Your handle looks like <code>name.bsky.social</code> or your own 169 domain. 170 </p> 171 )} 172 173 <form onSubmit={handleSubmit} className="login-form"> 174 <div className="login-input-wrapper"> 175 <input 176 ref={inputRef} 177 type="text" 178 className="login-input" 179 placeholder="yourname.bsky.social" 180 value={handle} 181 onChange={(e) => setHandle(e.target.value)} 182 onKeyDown={handleKeyDown} 183 onFocus={() => 184 handle.length >= 3 && 185 suggestions.length > 0 && 186 !handle.includes(".") && 187 setShowSuggestions(true) 188 } 189 autoComplete="off" 190 autoCapitalize="off" 191 autoCorrect="off" 192 spellCheck="false" 193 disabled={loading} 194 /> 195 196 {showSuggestions && suggestions.length > 0 && ( 197 <div className="login-suggestions" ref={suggestionsRef}> 198 {suggestions.map((actor, index) => ( 199 <button 200 key={actor.did} 201 type="button" 202 className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`} 203 onClick={() => selectSuggestion(actor)} 204 > 205 <div className="login-suggestion-avatar"> 206 {actor.avatar ? ( 207 <img src={actor.avatar} alt="" /> 208 ) : ( 209 <span> 210 {(actor.displayName || actor.handle) 211 .substring(0, 2) 212 .toUpperCase()} 213 </span> 214 )} 215 </div> 216 <div className="login-suggestion-info"> 217 <span className="login-suggestion-name"> 218 {actor.displayName || actor.handle} 219 </span> 220 <span className="login-suggestion-handle"> 221 @{actor.handle} 222 </span> 223 </div> 224 </button> 225 ))} 226 </div> 227 )} 228 </div> 229 230 {showInviteInput && ( 231 <div 232 className="login-input-wrapper" 233 style={{ marginTop: "12px", animation: "fadeIn 0.3s ease" }} 234 > 235 <input 236 ref={inviteRef} 237 type="text" 238 className="login-input" 239 placeholder="Enter invite code" 240 value={inviteCode} 241 onChange={(e) => setInviteCode(e.target.value)} 242 autoComplete="off" 243 disabled={loading} 244 style={{ borderColor: "var(--accent)" }} 245 /> 246 </div> 247 )} 248 249 {error && <p className="login-error">{error}</p>} 250 251 <button 252 type="submit" 253 className="btn btn-primary login-submit" 254 disabled={ 255 loading || !handle.trim() || (showInviteInput && !inviteCode.trim()) 256 } 257 > 258 {loading 259 ? "Connecting..." 260 : showInviteInput 261 ? "Submit Code" 262 : "Continue"} 263 </button> 264 </form> 265 </div> 266 ); 267}