an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
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 mt-4 mx-4 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 mt-4 mx-4"> 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 mt-4 mx-4"> 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 if (lastHandle) setHandle(lastHandle); 181 }, []); 182 183 const handleSubmit = (e: React.FormEvent) => { 184 e.preventDefault(); 185 if (handle.trim()) { 186 localStorage.setItem("lastHandle", handle); 187 loginWithOAuth(handle); 188 } 189 }; 190 return ( 191 <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 192 <p className="text-xs text-gray-500 dark:text-gray-400"> 193 Sign in with AT. Your password is never shared. 194 </p> 195 {/* <input 196 type="text" 197 placeholder="handle.bsky.social" 198 value={handle} 199 onChange={(e) => setHandle(e.target.value)} 200 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" 201 /> */} 202 <div className="flex flex-col gap-3"> 203 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 204 <input 205 type="text" 206 placeholder=" " 207 value={handle} 208 onChange={(e) => setHandle(e.target.value)} 209 /> 210 <label>AT Handle</label> 211 </div> 212 <button 213 type="submit" 214 className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 215 > 216 Log in 217 </button> 218 </div> 219 </form> 220 ); 221}; 222 223const PasswordForm = () => { 224 const { loginWithPassword } = useAuth(); 225 const [user, setUser] = useState(""); 226 const [password, setPassword] = useState(""); 227 const [serviceURL, setServiceURL] = useState("bsky.social"); 228 const [error, setError] = useState<string | null>(null); 229 230 useEffect(() => { 231 const lastHandle = localStorage.getItem("lastHandle"); 232 if (lastHandle) setUser(lastHandle); 233 }, []); 234 235 const handleSubmit = async (e: React.FormEvent) => { 236 e.preventDefault(); 237 setError(null); 238 try { 239 localStorage.setItem("lastHandle", user); 240 await loginWithPassword(user, password, `https://${serviceURL}`); 241 } catch (err) { 242 setError("Login failed. Check your handle and App Password."); 243 } 244 }; 245 246 return ( 247 <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 248 <p className="text-xs text-red-500 dark:text-red-400"> 249 Warning: Less secure. Use an App Password. 250 </p> 251 {/* <input 252 type="text" 253 placeholder="handle.bsky.social" 254 value={user} 255 onChange={(e) => setUser(e.target.value)} 256 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" 257 autoComplete="username" 258 /> 259 <input 260 type="password" 261 placeholder="App Password" 262 value={password} 263 onChange={(e) => setPassword(e.target.value)} 264 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" 265 autoComplete="current-password" 266 /> 267 <input 268 type="text" 269 placeholder="PDS (e.g., bsky.social)" 270 value={serviceURL} 271 onChange={(e) => setServiceURL(e.target.value)} 272 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" 273 /> */} 274 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 275 <input 276 type="text" 277 placeholder=" " 278 value={user} 279 onChange={(e) => setUser(e.target.value)} 280 /> 281 <label>AT Handle</label> 282 </div> 283 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 284 <input 285 type="text" 286 placeholder=" " 287 value={password} 288 onChange={(e) => setPassword(e.target.value)} 289 /> 290 <label>App Password</label> 291 </div> 292 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 293 <input 294 type="text" 295 placeholder=" " 296 value={serviceURL} 297 onChange={(e) => setServiceURL(e.target.value)} 298 /> 299 <label>PDS</label> 300 </div> 301 {error && <p className="text-xs text-red-500">{error}</p>} 302 <button 303 type="submit" 304 className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 305 > 306 Log in 307 </button> 308 </form> 309 ); 310}; 311 312// --- Profile Component (now supports a `large` prop for styling) --- 313export const ProfileThing = ({ 314 agent, 315 large = false, 316}: { 317 agent: Agent | null; 318 large?: boolean; 319}) => { 320 const did = ((agent as AtpAgent)?.session?.did ?? 321 (agent as AtpAgent)?.assertDid ?? 322 agent?.did) as string | undefined; 323 const { data: identity } = useQueryIdentity(did); 324 const { data: profiledata } = useQueryProfile( 325 `at://${did}/app.bsky.actor.profile/self` 326 ); 327 const profile = profiledata?.value; 328 329 const [imgcdn] = useAtom(imgCDNAtom) 330 331 function getAvatarUrl(p: typeof profile) { 332 const link = p?.avatar?.ref?.["$link"]; 333 if (!link || !did) return null; 334 return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`; 335 } 336 337 if (!profiledata) { 338 return ( 339 // Skeleton loader 340 <div 341 className={`flex items-center gap-2.5 animate-pulse ${large ? "mb-1" : ""}`} 342 > 343 <div 344 className={`rounded-full bg-gray-300 dark:bg-gray-700 ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`} 345 /> 346 <div className="flex flex-col gap-2"> 347 <div 348 className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-28" : "h-3 w-20"}`} 349 /> 350 <div 351 className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-20" : "h-3 w-16"}`} 352 /> 353 </div> 354 </div> 355 ); 356 } 357 358 return ( 359 <div 360 className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`} 361 > 362 <img 363 src={getAvatarUrl(profile) ?? undefined} 364 alt="avatar" 365 className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`} 366 /> 367 <div className="flex flex-col items-start text-left"> 368 <div 369 className={`font-medium ${large ? "text-gray-800 dark:text-gray-100 text-md" : "text-gray-800 dark:text-gray-100 text-sm"}`} 370 > 371 {profile?.displayName} 372 </div> 373 <div 374 className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`} 375 > 376 @{identity?.handle} 377 </div> 378 </div> 379 </div> 380 ); 381};