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