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 extract avatar
36 useEffect(() => {
37 const input = inputRef.current;
38 if (!input) return;
39
40 const handleInputChange = () => {
41 let value = input.value.trim();
42
43 // Strip leading @ if present
44 if (value.startsWith("@")) {
45 value = value.substring(1);
46 input.value = value;
47
48 // Show message once
49 if (!strippedAtMessage) {
50 setStrippedAtMessage(true);
51 setTimeout(() => setStrippedAtMessage(false), 3000);
52 }
53 }
54
55 // Check if typeahead has selection data (avatar)
56 const typeaheadElement = input.closest("actor-typeahead");
57 if (typeaheadElement) {
58 const avatar = typeaheadElement.getAttribute("data-avatar");
59 if (avatar) {
60 setSelectedAvatar(avatar);
61 } else if (value === "") {
62 // Clear avatar when input is cleared
63 setSelectedAvatar(null);
64 }
65 }
66
67 // Update form state
68 setValue("handle", value);
69 };
70
71 // Listen for input, change, and blur events to catch typeahead selections
72 input.addEventListener("input", handleInputChange);
73 input.addEventListener("change", handleInputChange);
74 input.addEventListener("blur", handleInputChange);
75
76 // Also listen for custom typeahead selection event if it exists
77 const handleSelection = (e: Event) => {
78 const customEvent = e as CustomEvent;
79 if (customEvent.detail?.avatar) {
80 setSelectedAvatar(customEvent.detail.avatar);
81 }
82 };
83 input.addEventListener("actor-select", handleSelection as EventListener);
84
85 return () => {
86 input.removeEventListener("input", handleInputChange);
87 input.removeEventListener("change", handleInputChange);
88 input.removeEventListener("blur", handleInputChange);
89 input.removeEventListener("actor-select", handleSelection as EventListener);
90 };
91 }, [setValue, strippedAtMessage]);
92
93 const handleSubmit = async (e: React.FormEvent) => {
94 e.preventDefault();
95
96 // Get the value directly from the input (in case form state is stale)
97 let currentHandle = (inputRef.current?.value || fields.handle.value).trim();
98
99 // Strip leading @ one more time to be sure
100 if (currentHandle.startsWith("@")) {
101 currentHandle = currentHandle.substring(1);
102 }
103
104 setValue("handle", currentHandle);
105
106 // Validate
107 const isValid = validate("handle", validateHandle);
108
109 if (!isValid) {
110 return;
111 }
112
113 setIsSubmitting(true);
114 try {
115 await onSubmit(currentHandle);
116 } catch (error) {
117 // Error handling is done in parent component
118 setIsSubmitting(false);
119 }
120 };
121
122 return (
123 <div className="min-h-screen">
124 <div className="max-w-6xl mx-auto px-4 py-8 md:py-12">
125 {/* Hero Section - Side by side on desktop */}
126 <div className="grid md:grid-cols-2 gap-8 md:gap-12 items-start mb-12 md:mb-16">
127 <HeroSection reducedMotion={reducedMotion} />
128
129 {/* Right: Login Card or Dashboard Button */}
130 <div className="w-full">
131 {session ? (
132 <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">
133 <div className="text-center mb-6">
134 <h2 className="text-2xl font-bold text-purple-950 dark:text-cyan-50 mb-2">
135 You're logged in!
136 </h2>
137 <p className="text-purple-750 dark:text-cyan-250">
138 Welcome back, @{session.handle}
139 </p>
140 </div>
141
142 <button
143 onClick={() => onNavigate?.("home")}
144 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"
145 >
146 <span>Go to Dashboard</span>
147 <ArrowRight className="w-5 h-5" />
148 </button>
149 </div>
150 ) : (
151 <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">
152 <h2 className="text-2xl font-bold text-purple-950 dark:text-cyan-50 mb-2 text-center">
153 Light Up Your Network
154 </h2>
155 <p className="text-purple-750 dark:text-cyan-250 text-center mb-6">
156 Reconnect in the ATmosphere as:
157 </p>
158
159 <form
160 onSubmit={handleSubmit}
161 className="space-y-4"
162 method="post"
163 >
164 <div>
165 <actor-typeahead rows={5}>
166 <HandleInput
167 ref={inputRef}
168 id="atproto-handle"
169 {...getFieldProps("handle")}
170 placeholder={placeholder}
171 error={fields.handle.touched && !!fields.handle.error}
172 selectedAvatar={selectedAvatar}
173 aria-required="true"
174 aria-invalid={
175 fields.handle.touched && !!fields.handle.error
176 }
177 aria-describedby={
178 fields.handle.error
179 ? "handle-error"
180 : "handle-description"
181 }
182 disabled={isSubmitting}
183 />
184 </actor-typeahead>
185 {strippedAtMessage && (
186 <div className="mt-2 flex items-center gap-2 text-sm text-cyan-700 dark:text-cyan-300">
187 <Info className="w-4 h-4 flex-shrink-0" />
188 <span>
189 No need for the @ symbol - we've removed it for you!
190 </span>
191 </div>
192 )}
193 {fields.handle.touched && fields.handle.error && (
194 <div
195 id="handle-error"
196 className="mt-2 flex items-center gap-2 text-sm text-red-600 dark:text-red-400"
197 role="alert"
198 >
199 <AlertCircle className="w-4 h-4 flex-shrink-0" />
200 <span>{fields.handle.error}</span>
201 </div>
202 )}
203 </div>
204
205 <button
206 type="submit"
207 disabled={isSubmitting}
208 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"
209 aria-label="Connect to the ATmosphere"
210 >
211 {isSubmitting ? (
212 <>
213 <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
214 <span>Connecting...</span>
215 </>
216 ) : (
217 "Join the Swarm"
218 )}
219 </button>
220 </form>
221
222 <div className="mt-6 pt-6 border-t-2 border-cyan-500/30 dark:border-purple-500/30">
223 <div className="flex items-start space-x-2 text-sm text-purple-900 dark:text-cyan-100">
224 <svg
225 className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5"
226 fill="currentColor"
227 viewBox="0 0 20 20"
228 aria-hidden="true"
229 >
230 <path
231 fillRule="evenodd"
232 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"
233 clipRule="evenodd"
234 />
235 </svg>
236 <div>
237 <p className="font-semibold text-purple-950 dark:text-cyan-50">
238 Secure OAuth Connection
239 </p>
240 <p className="text-xs mt-1">
241 You will be directed to your account to authorize
242 access. We never see your password and you can revoke
243 access anytime.
244 </p>
245 </div>
246 </div>
247 </div>
248 </div>
249 )}
250 </div>
251 </div>
252
253 <ValuePropsSection />
254 <HowItWorksSection />
255 </div>
256 </div>
257 );
258}