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