ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1import { useState, useRef, useEffect } from "react";
2import "actor-typeahead";
3import { ArrowRight, AlertCircle, Info } from "lucide-react";
4import { useFormValidation } from "../hooks/useFormValidation";
5import { validateHandle } from "../lib/validation";
6import { useRotatingPlaceholder } from "../hooks/useRotatingPlaceholder";
7import HeroSection from "../components/login/HeroSection";
8import ValuePropsSection from "../components/login/ValuePropsSection";
9import HowItWorksSection from "../components/login/HowItWorksSection";
10import HandleInput from "../components/login/HandleInput";
11import Tooltip from "../components/common/Tooltip";
12
13interface LoginPageProps {
14 onSubmit: (handle: string) => void;
15 session?: { handle: string } | null;
16 onNavigate?: (step: "home") => void;
17 reducedMotion?: boolean;
18}
19
20export default function LoginPage({
21 onSubmit,
22 session,
23 onNavigate,
24 reducedMotion = false,
25}: LoginPageProps) {
26 const inputRef = useRef<HTMLInputElement>(null);
27 const [isSubmitting, setIsSubmitting] = useState(false);
28 const [strippedAtMessage, setStrippedAtMessage] = useState(false);
29 const [selectedAvatar, setSelectedAvatar] = useState<string | null>(null);
30 const placeholder = useRotatingPlaceholder();
31
32 const { fields, setValue, validate, getFieldProps } = useFormValidation({
33 handle: "",
34 });
35
36 // Sync typeahead selection with form state and fetch avatar
37 useEffect(() => {
38 const input = inputRef.current;
39 if (!input) return;
40
41 let debounceTimer: ReturnType<typeof setTimeout>;
42
43 const fetchAvatar = async (handle: string) => {
44 if (!handle || handle.length < 3) {
45 setSelectedAvatar(null);
46 return;
47 }
48
49 try {
50 const url = new URL(
51 "xrpc/app.bsky.actor.searchActorsTypeahead",
52 "https://public.api.bsky.app"
53 );
54 url.searchParams.set("q", handle);
55 url.searchParams.set("limit", "1");
56
57 const res = await fetch(url);
58 const json = await res.json();
59
60 if (json.actors?.[0]?.avatar) {
61 setSelectedAvatar(json.actors[0].avatar);
62 } else {
63 setSelectedAvatar(null);
64 }
65 } catch (error) {
66 // Silently fail - avatar is optional
67 setSelectedAvatar(null);
68 }
69 };
70
71 const handleInputChange = () => {
72 let value = input.value.trim();
73
74 // Strip leading @ if present
75 if (value.startsWith("@")) {
76 value = value.substring(1);
77 input.value = value;
78
79 // Show message once
80 if (!strippedAtMessage) {
81 setStrippedAtMessage(true);
82 setTimeout(() => setStrippedAtMessage(false), 3000);
83 }
84 }
85
86 // Update form state
87 setValue("handle", value);
88
89 // Debounce avatar fetch
90 clearTimeout(debounceTimer);
91 if (value === "") {
92 setSelectedAvatar(null);
93 } else {
94 debounceTimer = setTimeout(() => fetchAvatar(value), 300);
95 }
96 };
97
98 // Listen for input and change events
99 input.addEventListener("input", handleInputChange);
100 input.addEventListener("change", handleInputChange);
101
102 return () => {
103 input.removeEventListener("input", handleInputChange);
104 input.removeEventListener("change", handleInputChange);
105 clearTimeout(debounceTimer);
106 };
107 }, [setValue, strippedAtMessage]);
108
109 const handleSubmit = async (e: React.FormEvent) => {
110 e.preventDefault();
111
112 // Get the value directly from the input (in case form state is stale)
113 let currentHandle = (inputRef.current?.value || fields.handle.value).trim();
114
115 // Strip leading @ one more time to be sure
116 if (currentHandle.startsWith("@")) {
117 currentHandle = currentHandle.substring(1);
118 }
119
120 setValue("handle", currentHandle);
121
122 // Validate
123 const isValid = validate("handle", validateHandle);
124
125 if (!isValid) {
126 return;
127 }
128
129 setIsSubmitting(true);
130 try {
131 await onSubmit(currentHandle);
132 } catch (error) {
133 // Error handling is done in parent component
134 setIsSubmitting(false);
135 }
136 };
137
138 return (
139 <div className="min-h-screen">
140 <div className="mx-auto max-w-6xl px-4 py-8 md:py-12">
141 {/* Hero Section - Side by side on desktop */}
142 <div className="mb-12 grid items-start gap-8 md:mb-16 md:grid-cols-2 md:gap-12">
143 <HeroSection reducedMotion={reducedMotion} />
144
145 {/* Right: Login Card or Dashboard Button */}
146 <div className="w-full">
147 {session ? (
148 <div className="rounded-3xl border-2 border-cyan-500/30 bg-white/50 p-8 shadow-2xl backdrop-blur-xl dark:border-purple-500/30 dark:bg-slate-900/50">
149 <div className="mb-6 text-center">
150 <h2 className="mb-2 text-2xl font-bold text-purple-950 dark:text-cyan-50">
151 You're logged in!
152 </h2>
153 <p className="text-purple-750 dark:text-cyan-250">
154 Welcome back, @{session.handle}
155 </p>
156 </div>
157
158 <button
159 onClick={() => onNavigate?.("home")}
160 className="flex w-full items-center justify-center space-x-2 rounded-xl bg-firefly-banner py-4 text-lg font-bold text-white shadow-lg transition-all hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-orange-500 dark:bg-firefly-banner-dark dark:focus:ring-amber-400"
161 >
162 <span>Go to Dashboard</span>
163 <ArrowRight className="size-5" />
164 </button>
165 </div>
166 ) : (
167 <div className="rounded-3xl border-2 border-cyan-500/30 bg-white/50 p-8 shadow-2xl backdrop-blur-xl dark:border-purple-500/30 dark:bg-slate-900/50">
168 <h2 className="mb-2 text-center text-2xl font-bold text-purple-950 dark:text-cyan-50">
169 Light Up Your Network
170 </h2>
171 <p className="mb-6 text-center text-purple-750 dark:text-cyan-250">
172 Reconnect in the ATmosphere
173 <sup className="ml-0.5">
174 <Tooltip
175 content={
176 <div className="text-left">
177 <p className="mb-1 font-semibold">
178 What's the ATmosphere?
179 </p>
180 <p className="text-xs leading-relaxed">
181 The <strong>ATmosphere</strong> is a shared home for
182 social apps using one login. Your follows stay with
183 you, even if you change apps.
184 </p>
185 </div>
186 }
187 />
188 </sup>{" "}
189 </p>
190
191 <form
192 onSubmit={handleSubmit}
193 className="space-y-4"
194 method="post"
195 >
196 <div>
197 <label
198 htmlFor="atproto-handle"
199 className="mb-2 block text-sm font-semibold text-purple-900 dark:text-cyan-100"
200 >
201 Your ATmosphere Handle
202 </label>
203 <actor-typeahead rows={5}>
204 <HandleInput
205 ref={inputRef}
206 id="atproto-handle"
207 {...getFieldProps("handle")}
208 placeholder={placeholder}
209 error={fields.handle.touched && !!fields.handle.error}
210 selectedAvatar={selectedAvatar}
211 aria-required="true"
212 aria-invalid={
213 fields.handle.touched && !!fields.handle.error
214 }
215 aria-describedby={
216 fields.handle.error
217 ? "handle-error"
218 : "handle-description"
219 }
220 disabled={isSubmitting}
221 />
222 </actor-typeahead>
223 {strippedAtMessage && (
224 <div className="mt-2 flex items-center gap-2 text-sm text-cyan-700 dark:text-cyan-300">
225 <Info className="size-4 flex-shrink-0" />
226 <span>
227 No need for the @ symbol - we've removed it for you!
228 </span>
229 </div>
230 )}
231 {fields.handle.touched && fields.handle.error && (
232 <div
233 id="handle-error"
234 className="mt-2 flex items-center gap-2 text-sm text-red-600 dark:text-red-400"
235 role="alert"
236 >
237 <AlertCircle className="size-4 flex-shrink-0" />
238 <span>{fields.handle.error}</span>
239 </div>
240 )}
241 </div>
242
243 <button
244 type="submit"
245 disabled={isSubmitting}
246 className="flex w-full items-center justify-center rounded-xl bg-firefly-banner py-4 text-lg font-bold text-white shadow-lg transition-all hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-orange-500 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-firefly-banner-dark dark:focus:ring-amber-400"
247 aria-label="Connect to the ATmosphere"
248 >
249 {isSubmitting ? (
250 <>
251 <div className="mr-2 size-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
252 <span>Connecting...</span>
253 </>
254 ) : (
255 "Join the Swarm"
256 )}
257 </button>
258 </form>
259
260 <div className="mt-6 border-t-2 border-cyan-500/30 pt-6 dark:border-purple-500/30">
261 <div className="flex items-start space-x-2 text-sm text-purple-900 dark:text-cyan-100">
262 <svg
263 className="mt-0.5 size-5 flex-shrink-0 text-green-500"
264 fill="currentColor"
265 viewBox="0 0 20 20"
266 aria-hidden="true"
267 >
268 <path
269 fillRule="evenodd"
270 d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
271 clipRule="evenodd"
272 />
273 </svg>
274 <div>
275 <p className="font-semibold text-purple-950 dark:text-cyan-50">
276 Secure OAuth Connection
277 </p>
278 <p className="mt-1 text-xs">
279 You will be directed to your account to authorize
280 access. We never see your password and you can revoke
281 access anytime.
282 </p>
283 </div>
284 </div>
285 </div>
286 </div>
287 )}
288 </div>
289 </div>
290
291 <ValuePropsSection />
292 <HowItWorksSection />
293 </div>
294 </div>
295 );
296}