ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
17
fork

Configure Feed

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

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