(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

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

at main 342 lines 14 kB view raw
1import React, { useState, useEffect, useRef } from "react"; 2import { AtSign, ShieldOff } from "lucide-react"; 3import { useTranslation } from "react-i18next"; 4import "../../i18n"; 5import SignUpModal from "../../components/modals/SignUpModal"; 6import { 7 searchActors, 8 startLogin, 9 type ActorSearchItem, 10} from "../../api/client"; 11import { Avatar } from "../../components/ui"; 12import { useStore } from "@nanostores/react"; 13import { $theme } from "../../store/theme"; 14import { analytics } from "../../lib/analytics"; 15 16interface LoginProps { 17 initialError?: string; 18} 19 20export default function Login({ initialError }: LoginProps) { 21 const { t } = useTranslation(); 22 useStore($theme); // ensure theme is applied on this page 23 const [handle, setHandle] = useState(""); 24 const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]); 25 const [showSuggestions, setShowSuggestions] = useState(false); 26 const [loading, setLoading] = useState(false); 27 const [error, setError] = useState<string | null>(initialError || null); 28 const [selectedIndex, setSelectedIndex] = useState(-1); 29 const [showSignUp, setShowSignUp] = useState(false); 30 31 const inputRef = useRef<HTMLInputElement>(null); 32 const suggestionsRef = useRef<HTMLDivElement>(null); 33 const isSelectionRef = useRef(false); 34 35 const [providerIndex, setProviderIndex] = useState(0); 36 const [morphClass, setMorphClass] = useState( 37 "opacity-100 translate-y-0 blur-0", 38 ); 39 const providers = [ 40 "AT Protocol", 41 "Margin", 42 "Bluesky", 43 "Eurosky", 44 "Blacksky", 45 "Tangled", 46 "Northsky", 47 "selfhosted.social", 48 "witchcraft.systems", 49 "tophhie.social", 50 "altq.net", 51 ]; 52 53 const [selectedAvatar, setSelectedAvatar] = useState<string | null>(null); 54 55 useEffect(() => { 56 const cycleText = () => { 57 setMorphClass("opacity-0 translate-y-2 blur-sm"); 58 setTimeout(() => { 59 setProviderIndex((prev) => (prev + 1) % providers.length); 60 setMorphClass("opacity-100 translate-y-0 blur-0"); 61 }, 400); 62 }; 63 const interval = setInterval(cycleText, 3000); 64 return () => clearInterval(interval); 65 }, [providers.length]); 66 67 useEffect(() => { 68 if (handle.length >= 3) { 69 if (isSelectionRef.current) { 70 isSelectionRef.current = false; 71 return; 72 } 73 const timer = setTimeout(async () => { 74 try { 75 if (!handle.includes(".")) { 76 const data = await searchActors(handle); 77 setSuggestions(data.actors || []); 78 79 const exactMatch = data.actors?.find((s) => s.handle === handle); 80 if (exactMatch) { 81 setSelectedAvatar(exactMatch.avatar || null); 82 } 83 84 setShowSuggestions(true); 85 setSelectedIndex(-1); 86 } 87 } catch (e) { 88 console.error("Search failed:", e); 89 } 90 }, 300); 91 return () => clearTimeout(timer); 92 } 93 }, [handle]); 94 95 useEffect(() => { 96 const handleClickOutside = (e: MouseEvent) => { 97 if ( 98 suggestionsRef.current && 99 !suggestionsRef.current.contains(e.target as Node) && 100 inputRef.current && 101 !inputRef.current.contains(e.target as Node) 102 ) { 103 setShowSuggestions(false); 104 } 105 }; 106 document.addEventListener("mousedown", handleClickOutside); 107 return () => document.removeEventListener("mousedown", handleClickOutside); 108 }, []); 109 110 const handleKeyDown = (e: React.KeyboardEvent) => { 111 if (!showSuggestions || suggestions.length === 0) return; 112 113 if (e.key === "ArrowDown") { 114 e.preventDefault(); 115 setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 116 } else if (e.key === "ArrowUp") { 117 e.preventDefault(); 118 setSelectedIndex((prev) => Math.max(prev - 1, -1)); 119 } else if (e.key === "Enter" && selectedIndex >= 0) { 120 e.preventDefault(); 121 selectSuggestion(suggestions[selectedIndex]); 122 } else if (e.key === "Escape") { 123 setShowSuggestions(false); 124 } 125 }; 126 127 const selectSuggestion = (actor: ActorSearchItem) => { 128 isSelectionRef.current = true; 129 setHandle(actor.handle); 130 setSelectedAvatar(actor.avatar || null); 131 setSuggestions([]); 132 setShowSuggestions(false); 133 inputRef.current?.blur(); 134 }; 135 136 const handleSubmit = async (e: React.FormEvent) => { 137 e.preventDefault(); 138 if (!handle.trim()) return; 139 140 setLoading(true); 141 setError(null); 142 143 try { 144 analytics.capture("login_initiated", { handle: handle.trim() }); 145 const result = await startLogin(handle.trim()); 146 if (result.authorizationUrl) { 147 const url = new URL(result.authorizationUrl); 148 if (url.protocol !== "https:") 149 throw new Error("Invalid authorization URL"); 150 window.location.href = result.authorizationUrl; 151 } 152 } catch (err) { 153 const message = err instanceof Error ? err.message : "Unknown error"; 154 analytics.captureException(err); 155 setError(message || "Failed to initiate login. Please try again."); 156 setLoading(false); 157 } 158 }; 159 160 if (initialError === "banned") { 161 return ( 162 <div className="relative min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4 overflow-hidden"> 163 <div className="pointer-events-none absolute inset-0 -z-0"> 164 <div className="absolute top-1/4 left-1/2 -translate-x-1/2 h-96 w-96 rounded-full bg-red-200/30 dark:bg-red-900/20 blur-3xl" /> 165 </div> 166 <div className="relative w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none text-center"> 167 <div className="flex justify-center mb-5"> 168 <div className="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 169 <ShieldOff size={28} className="text-red-500" /> 170 </div> 171 </div> 172 <h1 className="text-xl font-bold font-display text-surface-900 dark:text-white mb-2"> 173 {t("login.bannedTitle")} 174 </h1> 175 <p className="text-sm text-surface-500 dark:text-surface-400 mb-1 leading-relaxed"> 176 {t("login.bannedMessage")} 177 </p> 178 <p className="text-sm text-surface-500 dark:text-surface-400 mb-6 leading-relaxed"> 179 {t("login.bannedAppeal")}{" "} 180 <a 181 href="mailto:hello@margin.at" 182 className="text-[#027bff] hover:underline font-medium" 183 > 184 hello@margin.at 185 </a> 186 . 187 </p> 188 <button 189 onClick={async () => { 190 await fetch("/auth/logout", { method: "POST" }).catch(() => {}); 191 window.location.href = "/login"; 192 }} 193 className="w-full py-3 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-xl font-semibold transition-all text-sm" 194 > 195 {t("login.bannedSignOut")} 196 </button> 197 </div> 198 </div> 199 ); 200 } 201 202 return ( 203 <div className="relative min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4 overflow-hidden"> 204 <div className="pointer-events-none absolute inset-0 -z-0"> 205 <div className="absolute top-1/4 left-1/2 -translate-x-1/2 h-96 w-96 rounded-full bg-primary-200/30 dark:bg-primary-900/20 blur-3xl" /> 206 </div> 207 <div className="relative w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none"> 208 <div className="flex flex-col items-center mb-8"> 209 <h1 className="text-2xl font-bold font-display text-surface-900 dark:text-white text-center leading-snug"> 210 {t("login.signInWith")} <br /> 211 <span 212 className={`inline-block transition-all duration-400 ease-out text-transparent bg-clip-text bg-gradient-to-r from-[#027bff] to-[#0285FF] ${morphClass}`} 213 > 214 {providers[providerIndex]} 215 </span>{" "} 216 {t("login.handleSuffix")} 217 </h1> 218 </div> 219 220 <form onSubmit={handleSubmit} className="w-full flex flex-col gap-4"> 221 <div className="relative group"> 222 <div className="absolute left-4 top-1/2 -translate-y-1/2 text-surface-400 dark:text-surface-500 transition-colors pointer-events-none"> 223 {selectedAvatar ? ( 224 <Avatar 225 src={selectedAvatar} 226 size="xs" 227 className="ring-2 ring-white dark:ring-surface-900 shadow-sm" 228 /> 229 ) : ( 230 <AtSign 231 size={20} 232 className="stroke-[2.5] group-focus-within:text-[#027bff]" 233 /> 234 )} 235 </div> 236 <input 237 ref={inputRef} 238 type="text" 239 value={handle} 240 onChange={(e) => { 241 const val = e.target.value; 242 setHandle(val); 243 if (selectedAvatar) setSelectedAvatar(null); 244 if (val.length < 3) { 245 setSuggestions([]); 246 setShowSuggestions(false); 247 } 248 }} 249 onKeyDown={handleKeyDown} 250 onFocus={() => 251 handle.length >= 3 && 252 suggestions.length > 0 && 253 !handle.includes(".") && 254 setShowSuggestions(true) 255 } 256 placeholder={t("login.handlePlaceholder")} 257 className="w-full pl-12 pr-4 py-3.5 bg-surface-50 dark:bg-surface-950 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-[#027bff] dark:focus:border-[#027bff] outline-none focus:ring-4 focus:ring-[#027bff]/10 transition-all font-medium text-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500" 258 autoCapitalize="none" 259 autoCorrect="off" 260 autoComplete="off" 261 spellCheck={false} 262 disabled={loading} 263 /> 264 265 {showSuggestions && suggestions.length > 0 && ( 266 <div 267 ref={suggestionsRef} 268 className="absolute top-[calc(100%+8px)] left-0 right-0 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in max-h-[300px] overflow-y-auto" 269 > 270 {suggestions.map((actor, index) => ( 271 <button 272 key={actor.did} 273 type="button" 274 className={`w-full flex items-center gap-3 px-4 py-3 border-b border-surface-100 dark:border-surface-800 last:border-0 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors text-left ${index === selectedIndex ? "bg-surface-50 dark:bg-surface-800" : ""}`} 275 onClick={() => selectSuggestion(actor)} 276 > 277 <Avatar src={actor.avatar} size="sm" /> 278 <div className="min-w-0"> 279 <div className="font-semibold text-surface-900 dark:text-white truncate text-sm"> 280 {actor.displayName || actor.handle} 281 </div> 282 <div className="text-surface-500 dark:text-surface-400 text-xs truncate"> 283 @{actor.handle} 284 </div> 285 </div> 286 </button> 287 ))} 288 </div> 289 )} 290 </div> 291 292 {error && ( 293 <div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg border border-red-100 dark:border-red-800 text-center font-medium animate-fade-in"> 294 {error} 295 </div> 296 )} 297 298 <button 299 type="submit" 300 disabled={loading || !handle} 301 className="w-full py-3.5 bg-[#027bff] hover:bg-[#0269d9] text-white rounded-xl font-semibold text-base tracking-wide disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2 mt-2" 302 > 303 {loading ? t("login.connecting") : t("login.continue")} 304 </button> 305 306 <p className="text-center text-sm text-surface-400 dark:text-surface-500 mt-2 leading-relaxed"> 307 {t("login.termsPrefix")}{" "} 308 <a 309 href="/terms" 310 className="text-surface-900 dark:text-white hover:underline font-medium hover:text-[#027bff] dark:hover:text-[#027bff] transition-colors" 311 > 312 {t("login.termsLink")} 313 </a>{" "} 314 {t("login.termsAnd")}{" "} 315 <a 316 href="/privacy" 317 className="text-surface-900 dark:text-white hover:underline font-medium hover:text-[#027bff] dark:hover:text-[#027bff] transition-colors" 318 > 319 {t("login.privacyLink")} 320 </a> 321 </p> 322 323 <div className="flex items-center justify-center py-1"> 324 <span className="text-xs text-surface-300 dark:text-surface-600"> 325 {t("login.or")} 326 </span> 327 </div> 328 329 <button 330 type="button" 331 onClick={() => setShowSignUp(true)} 332 className="w-full py-2.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white bg-surface-50 dark:bg-surface-800/60 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-colors" 333 > 334 {t("login.createAccount")} 335 </button> 336 </form> 337 </div> 338 339 {showSignUp && <SignUpModal onClose={() => setShowSignUp(false)} />} 340 </div> 341 ); 342}