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