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