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