an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 383 lines 12 kB view raw
1// src/components/Login.tsx 2import AtpAgent, { Agent } from "@atproto/api"; 3import { useAtom } from "jotai"; 4import React, { useEffect, useRef, useState } from "react"; 5 6import { useAuth } from "~/providers/UnifiedAuthProvider"; 7import { imgCDNAtom } from "~/utils/atoms"; 8import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 10// --- 1. The Main Component (Orchestrator with `compact` prop) --- 11export default function Login({ 12 compact = false, 13 popup = false, 14}: { 15 compact?: boolean; 16 popup?: boolean; 17}) { 18 const { status, agent, logout } = useAuth(); 19 20 // Loading state can be styled differently based on the prop 21 if (status === "loading") { 22 return ( 23 <div 24 className={ 25 compact 26 ? "flex items-center justify-center p-1" 27 : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 flex justify-center items-center h-[280px]" 28 } 29 > 30 <span 31 className={`border-t-transparent rounded-full animate-spin ${ 32 compact 33 ? "w-5 h-5 border-2 border-gray-400" 34 : "w-8 h-8 border-4 border-gray-400" 35 }`} 36 /> 37 </div> 38 ); 39 } 40 41 // --- LOGGED IN STATE --- 42 if (status === "signedIn") { 43 // Large view 44 if (!compact) { 45 return ( 46 <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800"> 47 <div className="flex flex-col items-center justify-center text-center"> 48 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 49 You are logged in! 50 </p> 51 <ProfileThing agent={agent} large /> 52 <button 53 onClick={logout} 54 className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors" 55 > 56 Log out 57 </button> 58 </div> 59 </div> 60 ); 61 } 62 // Compact view 63 return ( 64 <div className="flex items-center gap-4"> 65 <ProfileThing agent={agent} /> 66 <button 67 onClick={logout} 68 className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 69 > 70 Log out 71 </button> 72 </div> 73 ); 74 } 75 76 // --- LOGGED OUT STATE --- 77 if (!compact) { 78 // Large view renders the form directly in the card 79 return ( 80 <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800"> 81 <UnifiedLoginForm /> 82 </div> 83 ); 84 } 85 86 // Compact view renders a button that toggles the form in a dropdown 87 return <CompactLoginButton popup={popup} />; 88} 89 90// --- 2. The Reusable, Self-Contained Login Form Component --- 91export function UnifiedLoginForm() { 92 const [mode, setMode] = useState<"oauth" | "password">("oauth"); 93 94 return ( 95 <div> 96 <div className="flex bg-gray-300 rounded-full dark:bg-gray-700 mb-4"> 97 <TabButton 98 label="OAuth" 99 active={mode === "oauth"} 100 onClick={() => setMode("oauth")} 101 /> 102 <TabButton 103 label="Password" 104 active={mode === "password"} 105 onClick={() => setMode("password")} 106 /> 107 </div> 108 {mode === "oauth" ? <OAuthForm /> : <PasswordForm />} 109 </div> 110 ); 111} 112 113// --- 3. Helper components for layouts, forms, and UI --- 114 115// A new component to contain the logic for the compact dropdown 116const CompactLoginButton = ({ popup }: { popup?: boolean }) => { 117 const [showForm, setShowForm] = useState(false); 118 const formRef = useRef<HTMLDivElement>(null); 119 120 useEffect(() => { 121 function handleClickOutside(event: MouseEvent) { 122 if (formRef.current && !formRef.current.contains(event.target as Node)) { 123 setShowForm(false); 124 } 125 } 126 if (showForm) { 127 document.addEventListener("mousedown", handleClickOutside); 128 } 129 return () => { 130 document.removeEventListener("mousedown", handleClickOutside); 131 }; 132 }, [showForm]); 133 134 return ( 135 <div className="relative" ref={formRef}> 136 <button 137 onClick={() => setShowForm(!showForm)} 138 className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded-full px-3 py-1 font-medium transition-colors" 139 > 140 Log in 141 </button> 142 {showForm && ( 143 <div 144 className={`absolute ${popup ? `bottom-[calc(100%)]` : `top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`} 145 > 146 <UnifiedLoginForm /> 147 </div> 148 )} 149 </div> 150 ); 151}; 152 153const TabButton = ({ 154 label, 155 active, 156 onClick, 157}: { 158 label: string; 159 active: boolean; 160 onClick: () => void; 161}) => ( 162 <button 163 onClick={onClick} 164 className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${ 165 active 166 ? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500" 167 : "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200" 168 }`} 169 > 170 {label} 171 </button> 172); 173 174const OAuthForm = () => { 175 const { loginWithOAuth } = useAuth(); 176 const [handle, setHandle] = useState(""); 177 178 useEffect(() => { 179 const lastHandle = localStorage.getItem("lastHandle"); 180 // eslint-disable-next-line react-hooks/set-state-in-effect 181 if (lastHandle) setHandle(lastHandle); 182 }, []); 183 184 const handleSubmit = (e: React.FormEvent) => { 185 e.preventDefault(); 186 if (handle.trim()) { 187 localStorage.setItem("lastHandle", handle); 188 loginWithOAuth(handle); 189 } 190 }; 191 return ( 192 <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 193 <p className="text-xs text-gray-500 dark:text-gray-400"> 194 Sign in with AT. Your password is never shared. 195 </p> 196 {/* <input 197 type="text" 198 placeholder="handle.bsky.social" 199 value={handle} 200 onChange={(e) => setHandle(e.target.value)} 201 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 202 /> */} 203 <div className="flex flex-col gap-3"> 204 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 205 <input 206 type="text" 207 placeholder=" " 208 value={handle} 209 onChange={(e) => setHandle(e.target.value)} 210 /> 211 <label>AT Handle</label> 212 </div> 213 <button 214 type="submit" 215 className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 216 > 217 Log in 218 </button> 219 </div> 220 </form> 221 ); 222}; 223 224const PasswordForm = () => { 225 const { loginWithPassword } = useAuth(); 226 const [user, setUser] = useState(""); 227 const [password, setPassword] = useState(""); 228 const [serviceURL, setServiceURL] = useState("bsky.social"); 229 const [error, setError] = useState<string | null>(null); 230 231 useEffect(() => { 232 const lastHandle = localStorage.getItem("lastHandle"); 233 // eslint-disable-next-line react-hooks/set-state-in-effect 234 if (lastHandle) setUser(lastHandle); 235 }, []); 236 237 const handleSubmit = async (e: React.FormEvent) => { 238 e.preventDefault(); 239 setError(null); 240 try { 241 localStorage.setItem("lastHandle", user); 242 await loginWithPassword(user, password, `https://${serviceURL}`); 243 } catch (err) { 244 setError("Login failed. Check your handle and App Password."); 245 } 246 }; 247 248 return ( 249 <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 250 <p className="text-xs text-red-500 dark:text-red-400"> 251 Less secure. Do not use your main password, please use an App Password. 252 </p> 253 {/* <input 254 type="text" 255 placeholder="handle.bsky.social" 256 value={user} 257 onChange={(e) => setUser(e.target.value)} 258 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 259 autoComplete="username" 260 /> 261 <input 262 type="password" 263 placeholder="App Password" 264 value={password} 265 onChange={(e) => setPassword(e.target.value)} 266 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 267 autoComplete="current-password" 268 /> 269 <input 270 type="text" 271 placeholder="PDS (e.g., bsky.social)" 272 value={serviceURL} 273 onChange={(e) => setServiceURL(e.target.value)} 274 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 275 /> */} 276 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 277 <input 278 type="text" 279 placeholder=" " 280 value={user} 281 onChange={(e) => setUser(e.target.value)} 282 /> 283 <label>AT Handle</label> 284 </div> 285 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 286 <input 287 type="text" 288 placeholder=" " 289 value={password} 290 onChange={(e) => setPassword(e.target.value)} 291 /> 292 <label>App Password</label> 293 </div> 294 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 295 <input 296 type="text" 297 placeholder=" " 298 value={serviceURL} 299 onChange={(e) => setServiceURL(e.target.value)} 300 /> 301 <label>PDS</label> 302 </div> 303 {error && <p className="text-xs text-red-500">{error}</p>} 304 <button 305 type="submit" 306 className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 307 > 308 Log in 309 </button> 310 </form> 311 ); 312}; 313 314// --- Profile Component (now supports a `large` prop for styling) --- 315export const ProfileThing = ({ 316 agent, 317 large = false, 318}: { 319 agent: Agent | null; 320 large?: boolean; 321}) => { 322 const did = ((agent as AtpAgent)?.session?.did ?? 323 (agent as AtpAgent)?.assertDid ?? 324 agent?.did) as string | undefined; 325 const { data: identity } = useQueryIdentity(did); 326 const { data: profiledata } = useQueryProfile( 327 `at://${did}/app.bsky.actor.profile/self` 328 ); 329 const profile = profiledata?.value; 330 331 const [imgcdn] = useAtom(imgCDNAtom) 332 333 function getAvatarUrl(p: typeof profile) { 334 const link = p?.avatar?.ref?.["$link"]; 335 if (!link || !did) return null; 336 return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`; 337 } 338 339 if (!profiledata) { 340 return ( 341 // Skeleton loader 342 <div 343 className={`flex items-center gap-2.5 animate-pulse ${large ? "mb-1" : ""}`} 344 > 345 <div 346 className={`rounded-full bg-gray-300 dark:bg-gray-700 ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`} 347 /> 348 <div className="flex flex-col gap-2"> 349 <div 350 className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-28" : "h-3 w-20"}`} 351 /> 352 <div 353 className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-20" : "h-3 w-16"}`} 354 /> 355 </div> 356 </div> 357 ); 358 } 359 360 return ( 361 <div 362 className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`} 363 > 364 <img 365 src={getAvatarUrl(profile) ?? undefined} 366 alt="avatar" 367 className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`} 368 /> 369 <div className="flex flex-col items-start text-left"> 370 <div 371 className={`font-medium ${large ? "text-gray-800 dark:text-gray-100 text-md" : "text-gray-800 dark:text-gray-100 text-sm"}`} 372 > 373 {profile?.displayName} 374 </div> 375 <div 376 className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`} 377 > 378 @{identity?.handle} 379 </div> 380 </div> 381 </div> 382 ); 383};