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
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 mt-4 mx-4 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 mt-4 mx-4">
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 mt-4 mx-4">
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 if (lastHandle) setHandle(lastHandle);
181 }, []);
182
183 const handleSubmit = (e: React.FormEvent) => {
184 e.preventDefault();
185 if (handle.trim()) {
186 localStorage.setItem("lastHandle", handle);
187 loginWithOAuth(handle);
188 }
189 };
190 return (
191 <form onSubmit={handleSubmit} className="flex flex-col gap-3">
192 <p className="text-xs text-gray-500 dark:text-gray-400">
193 Sign in with AT. Your password is never shared.
194 </p>
195 {/* <input
196 type="text"
197 placeholder="handle.bsky.social"
198 value={handle}
199 onChange={(e) => setHandle(e.target.value)}
200 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"
201 /> */}
202 <div className="flex flex-col gap-3">
203 <div className="m3input-field m3input-label m3input-border size-md flex-1">
204 <input
205 type="text"
206 placeholder=" "
207 value={handle}
208 onChange={(e) => setHandle(e.target.value)}
209 />
210 <label>AT Handle</label>
211 </div>
212 <button
213 type="submit"
214 className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
215 >
216 Log in
217 </button>
218 </div>
219 </form>
220 );
221};
222
223const PasswordForm = () => {
224 const { loginWithPassword } = useAuth();
225 const [user, setUser] = useState("");
226 const [password, setPassword] = useState("");
227 const [serviceURL, setServiceURL] = useState("bsky.social");
228 const [error, setError] = useState<string | null>(null);
229
230 useEffect(() => {
231 const lastHandle = localStorage.getItem("lastHandle");
232 if (lastHandle) setUser(lastHandle);
233 }, []);
234
235 const handleSubmit = async (e: React.FormEvent) => {
236 e.preventDefault();
237 setError(null);
238 try {
239 localStorage.setItem("lastHandle", user);
240 await loginWithPassword(user, password, `https://${serviceURL}`);
241 } catch (err) {
242 setError("Login failed. Check your handle and App Password.");
243 }
244 };
245
246 return (
247 <form onSubmit={handleSubmit} className="flex flex-col gap-3">
248 <p className="text-xs text-red-500 dark:text-red-400">
249 Warning: Less secure. Use an App Password.
250 </p>
251 {/* <input
252 type="text"
253 placeholder="handle.bsky.social"
254 value={user}
255 onChange={(e) => setUser(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 autoComplete="username"
258 />
259 <input
260 type="password"
261 placeholder="App Password"
262 value={password}
263 onChange={(e) => setPassword(e.target.value)}
264 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"
265 autoComplete="current-password"
266 />
267 <input
268 type="text"
269 placeholder="PDS (e.g., bsky.social)"
270 value={serviceURL}
271 onChange={(e) => setServiceURL(e.target.value)}
272 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"
273 /> */}
274 <div className="m3input-field m3input-label m3input-border size-md flex-1">
275 <input
276 type="text"
277 placeholder=" "
278 value={user}
279 onChange={(e) => setUser(e.target.value)}
280 />
281 <label>AT Handle</label>
282 </div>
283 <div className="m3input-field m3input-label m3input-border size-md flex-1">
284 <input
285 type="text"
286 placeholder=" "
287 value={password}
288 onChange={(e) => setPassword(e.target.value)}
289 />
290 <label>App Password</label>
291 </div>
292 <div className="m3input-field m3input-label m3input-border size-md flex-1">
293 <input
294 type="text"
295 placeholder=" "
296 value={serviceURL}
297 onChange={(e) => setServiceURL(e.target.value)}
298 />
299 <label>PDS</label>
300 </div>
301 {error && <p className="text-xs text-red-500">{error}</p>}
302 <button
303 type="submit"
304 className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
305 >
306 Log in
307 </button>
308 </form>
309 );
310};
311
312// --- Profile Component (now supports a `large` prop for styling) ---
313export const ProfileThing = ({
314 agent,
315 large = false,
316}: {
317 agent: Agent | null;
318 large?: boolean;
319}) => {
320 const did = ((agent as AtpAgent)?.session?.did ??
321 (agent as AtpAgent)?.assertDid ??
322 agent?.did) as string | undefined;
323 const { data: identity } = useQueryIdentity(did);
324 const { data: profiledata } = useQueryProfile(
325 `at://${did}/app.bsky.actor.profile/self`
326 );
327 const profile = profiledata?.value;
328
329 const [imgcdn] = useAtom(imgCDNAtom)
330
331 function getAvatarUrl(p: typeof profile) {
332 const link = p?.avatar?.ref?.["$link"];
333 if (!link || !did) return null;
334 return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`;
335 }
336
337 if (!profiledata) {
338 return (
339 // Skeleton loader
340 <div
341 className={`flex items-center gap-2.5 animate-pulse ${large ? "mb-1" : ""}`}
342 >
343 <div
344 className={`rounded-full bg-gray-300 dark:bg-gray-700 ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`}
345 />
346 <div className="flex flex-col gap-2">
347 <div
348 className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-28" : "h-3 w-20"}`}
349 />
350 <div
351 className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-20" : "h-3 w-16"}`}
352 />
353 </div>
354 </div>
355 );
356 }
357
358 return (
359 <div
360 className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`}
361 >
362 <img
363 src={getAvatarUrl(profile) ?? undefined}
364 alt="avatar"
365 className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`}
366 />
367 <div className="flex flex-col items-start text-left">
368 <div
369 className={`font-medium ${large ? "text-gray-800 dark:text-gray-100 text-md" : "text-gray-800 dark:text-gray-100 text-sm"}`}
370 >
371 {profile?.displayName}
372 </div>
373 <div
374 className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`}
375 >
376 @{identity?.handle}
377 </div>
378 </div>
379 </div>
380 );
381};