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