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