ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1import { useState, useRef, useEffect } from "react"; 2import "actor-typeahead"; 3import { ArrowRight, AlertCircle, Info } from "lucide-react"; 4import { useFormValidation } from "../hooks/useFormValidation"; 5import { validateHandle } from "../lib/validation"; 6import { useRotatingPlaceholder } from "../hooks/useRotatingPlaceholder"; 7import HeroSection from "../components/login/HeroSection"; 8import ValuePropsSection from "../components/login/ValuePropsSection"; 9import HowItWorksSection from "../components/login/HowItWorksSection"; 10import HandleInput from "../components/login/HandleInput"; 11 12interface LoginPageProps { 13 onSubmit: (handle: string) => void; 14 session?: { handle: string } | null; 15 onNavigate?: (step: "home") => void; 16 reducedMotion?: boolean; 17} 18 19export default function LoginPage({ 20 onSubmit, 21 session, 22 onNavigate, 23 reducedMotion = false, 24}: LoginPageProps) { 25 const inputRef = useRef<HTMLInputElement>(null); 26 const [isSubmitting, setIsSubmitting] = useState(false); 27 const [strippedAtMessage, setStrippedAtMessage] = useState(false); 28 const [selectedAvatar, setSelectedAvatar] = useState<string | null>(null); 29 const placeholder = useRotatingPlaceholder(); 30 31 const { fields, setValue, validate, getFieldProps } = useFormValidation({ 32 handle: "", 33 }); 34 35 // Sync typeahead selection with form state and fetch avatar 36 useEffect(() => { 37 const input = inputRef.current; 38 if (!input) return; 39 40 let debounceTimer: ReturnType<typeof setTimeout>; 41 42 const fetchAvatar = async (handle: string) => { 43 if (!handle || handle.length < 3) { 44 setSelectedAvatar(null); 45 return; 46 } 47 48 try { 49 const url = new URL( 50 "xrpc/app.bsky.actor.searchActorsTypeahead", 51 "https://public.api.bsky.app" 52 ); 53 url.searchParams.set("q", handle); 54 url.searchParams.set("limit", "1"); 55 56 const res = await fetch(url); 57 const json = await res.json(); 58 59 if (json.actors?.[0]?.avatar) { 60 setSelectedAvatar(json.actors[0].avatar); 61 } else { 62 setSelectedAvatar(null); 63 } 64 } catch (error) { 65 // Silently fail - avatar is optional 66 setSelectedAvatar(null); 67 } 68 }; 69 70 const handleInputChange = () => { 71 let value = input.value.trim(); 72 73 // Strip leading @ if present 74 if (value.startsWith("@")) { 75 value = value.substring(1); 76 input.value = value; 77 78 // Show message once 79 if (!strippedAtMessage) { 80 setStrippedAtMessage(true); 81 setTimeout(() => setStrippedAtMessage(false), 3000); 82 } 83 } 84 85 // Update form state 86 setValue("handle", value); 87 88 // Debounce avatar fetch 89 clearTimeout(debounceTimer); 90 if (value === "") { 91 setSelectedAvatar(null); 92 } else { 93 debounceTimer = setTimeout(() => fetchAvatar(value), 300); 94 } 95 }; 96 97 // Listen for input and change events 98 input.addEventListener("input", handleInputChange); 99 input.addEventListener("change", handleInputChange); 100 101 return () => { 102 input.removeEventListener("input", handleInputChange); 103 input.removeEventListener("change", handleInputChange); 104 clearTimeout(debounceTimer); 105 }; 106 }, [setValue, strippedAtMessage]); 107 108 const handleSubmit = async (e: React.FormEvent) => { 109 e.preventDefault(); 110 111 // Get the value directly from the input (in case form state is stale) 112 let currentHandle = (inputRef.current?.value || fields.handle.value).trim(); 113 114 // Strip leading @ one more time to be sure 115 if (currentHandle.startsWith("@")) { 116 currentHandle = currentHandle.substring(1); 117 } 118 119 setValue("handle", currentHandle); 120 121 // Validate 122 const isValid = validate("handle", validateHandle); 123 124 if (!isValid) { 125 return; 126 } 127 128 setIsSubmitting(true); 129 try { 130 await onSubmit(currentHandle); 131 } catch (error) { 132 // Error handling is done in parent component 133 setIsSubmitting(false); 134 } 135 }; 136 137 return ( 138 <div className="min-h-screen"> 139 <div className="max-w-6xl mx-auto px-4 py-8 md:py-12"> 140 {/* Hero Section - Side by side on desktop */} 141 <div className="grid md:grid-cols-2 gap-8 md:gap-12 items-start mb-12 md:mb-16"> 142 <HeroSection reducedMotion={reducedMotion} /> 143 144 {/* Right: Login Card or Dashboard Button */} 145 <div className="w-full"> 146 {session ? ( 147 <div className="bg-white/50 dark:bg-slate-900/50 border-cyan-500/30 dark:border-purple-500/30 backdrop-blur-xl rounded-3xl p-8 border-2 shadow-2xl"> 148 <div className="text-center mb-6"> 149 <h2 className="text-2xl font-bold text-purple-950 dark:text-cyan-50 mb-2"> 150 You're logged in! 151 </h2> 152 <p className="text-purple-750 dark:text-cyan-250"> 153 Welcome back, @{session.handle} 154 </p> 155 </div> 156 157 <button 158 onClick={() => onNavigate?.("home")} 159 className="w-full bg-firefly-banner dark:bg-firefly-banner-dark text-white py-4 rounded-xl font-bold text-lg transition-all shadow-lg hover:shadow-xl focus:ring-4 focus:ring-orange-500 dark:focus:ring-amber-400 focus:outline-none flex items-center justify-center space-x-2" 160 > 161 <span>Go to Dashboard</span> 162 <ArrowRight className="w-5 h-5" /> 163 </button> 164 </div> 165 ) : ( 166 <div className="bg-white/50 dark:bg-slate-900/50 border-cyan-500/30 dark:border-purple-500/30 backdrop-blur-xl rounded-3xl p-8 border-2 shadow-2xl"> 167 <h2 className="text-2xl font-bold text-purple-950 dark:text-cyan-50 mb-2 text-center"> 168 Light Up Your Network 169 </h2> 170 <p className="text-purple-750 dark:text-cyan-250 text-center mb-6"> 171 Reconnect in the ATmosphere as: 172 </p> 173 174 <form 175 onSubmit={handleSubmit} 176 className="space-y-4" 177 method="post" 178 > 179 <div> 180 <actor-typeahead rows={5}> 181 <HandleInput 182 ref={inputRef} 183 id="atproto-handle" 184 {...getFieldProps("handle")} 185 placeholder={placeholder} 186 error={fields.handle.touched && !!fields.handle.error} 187 selectedAvatar={selectedAvatar} 188 aria-required="true" 189 aria-invalid={ 190 fields.handle.touched && !!fields.handle.error 191 } 192 aria-describedby={ 193 fields.handle.error 194 ? "handle-error" 195 : "handle-description" 196 } 197 disabled={isSubmitting} 198 /> 199 </actor-typeahead> 200 {strippedAtMessage && ( 201 <div className="mt-2 flex items-center gap-2 text-sm text-cyan-700 dark:text-cyan-300"> 202 <Info className="w-4 h-4 flex-shrink-0" /> 203 <span> 204 No need for the @ symbol - we've removed it for you! 205 </span> 206 </div> 207 )} 208 {fields.handle.touched && fields.handle.error && ( 209 <div 210 id="handle-error" 211 className="mt-2 flex items-center gap-2 text-sm text-red-600 dark:text-red-400" 212 role="alert" 213 > 214 <AlertCircle className="w-4 h-4 flex-shrink-0" /> 215 <span>{fields.handle.error}</span> 216 </div> 217 )} 218 </div> 219 220 <button 221 type="submit" 222 disabled={isSubmitting} 223 className="w-full bg-firefly-banner dark:bg-firefly-banner-dark text-white py-4 rounded-xl font-bold text-lg transition-all shadow-lg hover:shadow-xl focus:ring-4 focus:ring-orange-500 dark:focus:ring-amber-400 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center" 224 aria-label="Connect to the ATmosphere" 225 > 226 {isSubmitting ? ( 227 <> 228 <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" /> 229 <span>Connecting...</span> 230 </> 231 ) : ( 232 "Join the Swarm" 233 )} 234 </button> 235 </form> 236 237 <div className="mt-6 pt-6 border-t-2 border-cyan-500/30 dark:border-purple-500/30"> 238 <div className="flex items-start space-x-2 text-sm text-purple-900 dark:text-cyan-100"> 239 <svg 240 className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" 241 fill="currentColor" 242 viewBox="0 0 20 20" 243 aria-hidden="true" 244 > 245 <path 246 fillRule="evenodd" 247 d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" 248 clipRule="evenodd" 249 /> 250 </svg> 251 <div> 252 <p className="font-semibold text-purple-950 dark:text-cyan-50"> 253 Secure OAuth Connection 254 </p> 255 <p className="text-xs mt-1"> 256 You will be directed to your account to authorize 257 access. We never see your password and you can revoke 258 access anytime. 259 </p> 260 </div> 261 </div> 262 </div> 263 </div> 264 )} 265 </div> 266 </div> 267 268 <ValuePropsSection /> 269 <HowItWorksSection /> 270 </div> 271 </div> 272 ); 273}