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