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};