an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at fe8f474c328c61f64bc6e2e22fa1e970707a159e 251 lines 10 kB view raw
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};