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 extract avatar 36 useEffect(() => { 37 const input = inputRef.current; 38 if (!input) return; 39 40 const handleInputChange = () => { 41 let value = input.value.trim(); 42 43 // Strip leading @ if present 44 if (value.startsWith("@")) { 45 value = value.substring(1); 46 input.value = value; 47 48 // Show message once 49 if (!strippedAtMessage) { 50 setStrippedAtMessage(true); 51 setTimeout(() => setStrippedAtMessage(false), 3000); 52 } 53 } 54 55 // Check if typeahead has selection data (avatar) 56 const typeaheadElement = input.closest("actor-typeahead"); 57 if (typeaheadElement) { 58 const avatar = typeaheadElement.getAttribute("data-avatar"); 59 if (avatar) { 60 setSelectedAvatar(avatar); 61 } else if (value === "") { 62 // Clear avatar when input is cleared 63 setSelectedAvatar(null); 64 } 65 } 66 67 // Update form state 68 setValue("handle", value); 69 }; 70 71 // Listen for input, change, and blur events to catch typeahead selections 72 input.addEventListener("input", handleInputChange); 73 input.addEventListener("change", handleInputChange); 74 input.addEventListener("blur", handleInputChange); 75 76 // Also listen for custom typeahead selection event if it exists 77 const handleSelection = (e: Event) => { 78 const customEvent = e as CustomEvent; 79 if (customEvent.detail?.avatar) { 80 setSelectedAvatar(customEvent.detail.avatar); 81 } 82 }; 83 input.addEventListener("actor-select", handleSelection as EventListener); 84 85 return () => { 86 input.removeEventListener("input", handleInputChange); 87 input.removeEventListener("change", handleInputChange); 88 input.removeEventListener("blur", handleInputChange); 89 input.removeEventListener("actor-select", handleSelection as EventListener); 90 }; 91 }, [setValue, strippedAtMessage]); 92 93 const handleSubmit = async (e: React.FormEvent) => { 94 e.preventDefault(); 95 96 // Get the value directly from the input (in case form state is stale) 97 let currentHandle = (inputRef.current?.value || fields.handle.value).trim(); 98 99 // Strip leading @ one more time to be sure 100 if (currentHandle.startsWith("@")) { 101 currentHandle = currentHandle.substring(1); 102 } 103 104 setValue("handle", currentHandle); 105 106 // Validate 107 const isValid = validate("handle", validateHandle); 108 109 if (!isValid) { 110 return; 111 } 112 113 setIsSubmitting(true); 114 try { 115 await onSubmit(currentHandle); 116 } catch (error) { 117 // Error handling is done in parent component 118 setIsSubmitting(false); 119 } 120 }; 121 122 return ( 123 <div className="min-h-screen"> 124 <div className="max-w-6xl mx-auto px-4 py-8 md:py-12"> 125 {/* Hero Section - Side by side on desktop */} 126 <div className="grid md:grid-cols-2 gap-8 md:gap-12 items-start mb-12 md:mb-16"> 127 <HeroSection reducedMotion={reducedMotion} /> 128 129 {/* Right: Login Card or Dashboard Button */} 130 <div className="w-full"> 131 {session ? ( 132 <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"> 133 <div className="text-center mb-6"> 134 <h2 className="text-2xl font-bold text-purple-950 dark:text-cyan-50 mb-2"> 135 You're logged in! 136 </h2> 137 <p className="text-purple-750 dark:text-cyan-250"> 138 Welcome back, @{session.handle} 139 </p> 140 </div> 141 142 <button 143 onClick={() => onNavigate?.("home")} 144 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" 145 > 146 <span>Go to Dashboard</span> 147 <ArrowRight className="w-5 h-5" /> 148 </button> 149 </div> 150 ) : ( 151 <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"> 152 <h2 className="text-2xl font-bold text-purple-950 dark:text-cyan-50 mb-2 text-center"> 153 Light Up Your Network 154 </h2> 155 <p className="text-purple-750 dark:text-cyan-250 text-center mb-6"> 156 Reconnect in the ATmosphere as: 157 </p> 158 159 <form 160 onSubmit={handleSubmit} 161 className="space-y-4" 162 method="post" 163 > 164 <div> 165 <actor-typeahead rows={5}> 166 <HandleInput 167 ref={inputRef} 168 id="atproto-handle" 169 {...getFieldProps("handle")} 170 placeholder={placeholder} 171 error={fields.handle.touched && !!fields.handle.error} 172 selectedAvatar={selectedAvatar} 173 aria-required="true" 174 aria-invalid={ 175 fields.handle.touched && !!fields.handle.error 176 } 177 aria-describedby={ 178 fields.handle.error 179 ? "handle-error" 180 : "handle-description" 181 } 182 disabled={isSubmitting} 183 /> 184 </actor-typeahead> 185 {strippedAtMessage && ( 186 <div className="mt-2 flex items-center gap-2 text-sm text-cyan-700 dark:text-cyan-300"> 187 <Info className="w-4 h-4 flex-shrink-0" /> 188 <span> 189 No need for the @ symbol - we've removed it for you! 190 </span> 191 </div> 192 )} 193 {fields.handle.touched && fields.handle.error && ( 194 <div 195 id="handle-error" 196 className="mt-2 flex items-center gap-2 text-sm text-red-600 dark:text-red-400" 197 role="alert" 198 > 199 <AlertCircle className="w-4 h-4 flex-shrink-0" /> 200 <span>{fields.handle.error}</span> 201 </div> 202 )} 203 </div> 204 205 <button 206 type="submit" 207 disabled={isSubmitting} 208 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" 209 aria-label="Connect to the ATmosphere" 210 > 211 {isSubmitting ? ( 212 <> 213 <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" /> 214 <span>Connecting...</span> 215 </> 216 ) : ( 217 "Join the Swarm" 218 )} 219 </button> 220 </form> 221 222 <div className="mt-6 pt-6 border-t-2 border-cyan-500/30 dark:border-purple-500/30"> 223 <div className="flex items-start space-x-2 text-sm text-purple-900 dark:text-cyan-100"> 224 <svg 225 className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" 226 fill="currentColor" 227 viewBox="0 0 20 20" 228 aria-hidden="true" 229 > 230 <path 231 fillRule="evenodd" 232 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" 233 clipRule="evenodd" 234 /> 235 </svg> 236 <div> 237 <p className="font-semibold text-purple-950 dark:text-cyan-50"> 238 Secure OAuth Connection 239 </p> 240 <p className="text-xs mt-1"> 241 You will be directed to your account to authorize 242 access. We never see your password and you can revoke 243 access anytime. 244 </p> 245 </div> 246 </div> 247 </div> 248 </div> 249 )} 250 </div> 251 </div> 252 253 <ValuePropsSection /> 254 <HowItWorksSection /> 255 </div> 256 </div> 257 ); 258}