an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at button 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 10import { Button } from "./radix-m3-rd/Button"; 11 12// --- 1. The Main Component (Orchestrator with `compact` prop) --- 13export default function Login({ 14 compact = false, 15 popup = false, 16}: { 17 compact?: boolean; 18 popup?: boolean; 19}) { 20 const { status, agent, logout } = useAuth(); 21 22 // Loading state can be styled differently based on the prop 23 if (status === "loading") { 24 return ( 25 <div 26 className={ 27 compact 28 ? "flex items-center justify-center p-1" 29 : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]" 30 } 31 > 32 <span 33 className={`border-t-transparent rounded-full animate-spin ${ 34 compact 35 ? "w-5 h-5 border-2 border-gray-400" 36 : "w-8 h-8 border-4 border-gray-400" 37 }`} 38 /> 39 </div> 40 ); 41 } 42 43 // --- LOGGED IN STATE --- 44 if (status === "signedIn") { 45 // Large view 46 if (!compact) { 47 return ( 48 <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 49 <div className="flex flex-col items-center justify-center text-center"> 50 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 51 You are logged in! 52 </p> 53 <ProfileThing agent={agent} large /> 54 <Button 55 onClick={logout} 56 className="mt-4" 57 > 58 Log out 59 </Button> 60 </div> 61 </div> 62 ); 63 } 64 // Compact view 65 return ( 66 <div className="flex items-center gap-4"> 67 <ProfileThing agent={agent} /> 68 <button 69 onClick={logout} 70 className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 71 > 72 Log out 73 </button> 74 </div> 75 ); 76 } 77 78 // --- LOGGED OUT STATE --- 79 if (!compact) { 80 // Large view renders the form directly in the card 81 return ( 82 <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 83 <UnifiedLoginForm /> 84 </div> 85 ); 86 } 87 88 // Compact view renders a button that toggles the form in a dropdown 89 return <CompactLoginButton popup={popup} />; 90} 91 92// --- 2. The Reusable, Self-Contained Login Form Component --- 93export function UnifiedLoginForm() { 94 const [mode, setMode] = useState<"oauth" | "password">("oauth"); 95 96 return ( 97 <div> 98 <div className="flex bg-gray-300 rounded-full dark:bg-gray-700 mb-4"> 99 <TabButton 100 label="OAuth" 101 active={mode === "oauth"} 102 onClick={() => setMode("oauth")} 103 /> 104 <TabButton 105 label="Password" 106 active={mode === "password"} 107 onClick={() => setMode("password")} 108 /> 109 </div> 110 {mode === "oauth" ? <OAuthForm /> : <PasswordForm />} 111 </div> 112 ); 113} 114 115// --- 3. Helper components for layouts, forms, and UI --- 116 117// A new component to contain the logic for the compact dropdown 118const CompactLoginButton = ({ popup }: { popup?: boolean }) => { 119 const [showForm, setShowForm] = useState(false); 120 const formRef = useRef<HTMLDivElement>(null); 121 122 useEffect(() => { 123 function handleClickOutside(event: MouseEvent) { 124 if (formRef.current && !formRef.current.contains(event.target as Node)) { 125 setShowForm(false); 126 } 127 } 128 if (showForm) { 129 document.addEventListener("mousedown", handleClickOutside); 130 } 131 return () => { 132 document.removeEventListener("mousedown", handleClickOutside); 133 }; 134 }, [showForm]); 135 136 return ( 137 <div className="relative" ref={formRef}> 138 <button 139 onClick={() => setShowForm(!showForm)} 140 className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded-full px-3 py-1 font-medium transition-colors" 141 > 142 Log in 143 </button> 144 {showForm && ( 145 <div 146 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`} 147 > 148 <UnifiedLoginForm /> 149 </div> 150 )} 151 </div> 152 ); 153}; 154 155const TabButton = ({ 156 label, 157 active, 158 onClick, 159}: { 160 label: string; 161 active: boolean; 162 onClick: () => void; 163}) => ( 164 <button 165 onClick={onClick} 166 className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${ 167 active 168 ? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500" 169 : "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200" 170 }`} 171 > 172 {label} 173 </button> 174); 175 176const OAuthForm = () => { 177 const { loginWithOAuth } = useAuth(); 178 const [handle, setHandle] = useState(""); 179 180 useEffect(() => { 181 const lastHandle = localStorage.getItem("lastHandle"); 182 if (lastHandle) setHandle(lastHandle); 183 }, []); 184 185 const handleSubmit = (e: React.FormEvent) => { 186 e.preventDefault(); 187 if (handle.trim()) { 188 localStorage.setItem("lastHandle", handle); 189 loginWithOAuth(handle); 190 } 191 }; 192 return ( 193 <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 194 <p className="text-xs text-gray-500 dark:text-gray-400"> 195 Sign in with AT. Your password is never shared. 196 </p> 197 {/* <input 198 type="text" 199 placeholder="handle.bsky.social" 200 value={handle} 201 onChange={(e) => setHandle(e.target.value)} 202 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" 203 /> */} 204 <div className="flex flex-col gap-3"> 205 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 206 <input 207 type="text" 208 placeholder=" " 209 value={handle} 210 onChange={(e) => setHandle(e.target.value)} 211 /> 212 <label>AT Handle</label> 213 </div> 214 <button 215 type="submit" 216 className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 217 > 218 Log in 219 </button> 220 </div> 221 </form> 222 ); 223}; 224 225const PasswordForm = () => { 226 const { loginWithPassword } = useAuth(); 227 const [user, setUser] = useState(""); 228 const [password, setPassword] = useState(""); 229 const [serviceURL, setServiceURL] = useState("bsky.social"); 230 const [error, setError] = useState<string | null>(null); 231 232 useEffect(() => { 233 const lastHandle = localStorage.getItem("lastHandle"); 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 Warning: Less secure. 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};