(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect, useRef } from "react";
2import { AtSign, ShieldOff } from "lucide-react";
3import { useTranslation } from "react-i18next";
4import "../../i18n";
5import SignUpModal from "../../components/modals/SignUpModal";
6import {
7 searchActors,
8 startLogin,
9 type ActorSearchItem,
10} from "../../api/client";
11import { Avatar } from "../../components/ui";
12import { useStore } from "@nanostores/react";
13import { $theme } from "../../store/theme";
14import { analytics } from "../../lib/analytics";
15
16interface LoginProps {
17 initialError?: string;
18}
19
20export default function Login({ initialError }: LoginProps) {
21 const { t } = useTranslation();
22 useStore($theme); // ensure theme is applied on this page
23 const [handle, setHandle] = useState("");
24 const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]);
25 const [showSuggestions, setShowSuggestions] = useState(false);
26 const [loading, setLoading] = useState(false);
27 const [error, setError] = useState<string | null>(initialError || null);
28 const [selectedIndex, setSelectedIndex] = useState(-1);
29 const [showSignUp, setShowSignUp] = useState(false);
30
31 const inputRef = useRef<HTMLInputElement>(null);
32 const suggestionsRef = useRef<HTMLDivElement>(null);
33 const isSelectionRef = useRef(false);
34
35 const [providerIndex, setProviderIndex] = useState(0);
36 const [morphClass, setMorphClass] = useState(
37 "opacity-100 translate-y-0 blur-0",
38 );
39 const providers = [
40 "AT Protocol",
41 "Margin",
42 "Bluesky",
43 "Eurosky",
44 "Blacksky",
45 "Tangled",
46 "Northsky",
47 "selfhosted.social",
48 "witchcraft.systems",
49 "tophhie.social",
50 "altq.net",
51 ];
52
53 const [selectedAvatar, setSelectedAvatar] = useState<string | null>(null);
54
55 useEffect(() => {
56 const cycleText = () => {
57 setMorphClass("opacity-0 translate-y-2 blur-sm");
58 setTimeout(() => {
59 setProviderIndex((prev) => (prev + 1) % providers.length);
60 setMorphClass("opacity-100 translate-y-0 blur-0");
61 }, 400);
62 };
63 const interval = setInterval(cycleText, 3000);
64 return () => clearInterval(interval);
65 }, [providers.length]);
66
67 useEffect(() => {
68 if (handle.length >= 3) {
69 if (isSelectionRef.current) {
70 isSelectionRef.current = false;
71 return;
72 }
73 const timer = setTimeout(async () => {
74 try {
75 if (!handle.includes(".")) {
76 const data = await searchActors(handle);
77 setSuggestions(data.actors || []);
78
79 const exactMatch = data.actors?.find((s) => s.handle === handle);
80 if (exactMatch) {
81 setSelectedAvatar(exactMatch.avatar || null);
82 }
83
84 setShowSuggestions(true);
85 setSelectedIndex(-1);
86 }
87 } catch (e) {
88 console.error("Search failed:", e);
89 }
90 }, 300);
91 return () => clearTimeout(timer);
92 }
93 }, [handle]);
94
95 useEffect(() => {
96 const handleClickOutside = (e: MouseEvent) => {
97 if (
98 suggestionsRef.current &&
99 !suggestionsRef.current.contains(e.target as Node) &&
100 inputRef.current &&
101 !inputRef.current.contains(e.target as Node)
102 ) {
103 setShowSuggestions(false);
104 }
105 };
106 document.addEventListener("mousedown", handleClickOutside);
107 return () => document.removeEventListener("mousedown", handleClickOutside);
108 }, []);
109
110 const handleKeyDown = (e: React.KeyboardEvent) => {
111 if (!showSuggestions || suggestions.length === 0) return;
112
113 if (e.key === "ArrowDown") {
114 e.preventDefault();
115 setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
116 } else if (e.key === "ArrowUp") {
117 e.preventDefault();
118 setSelectedIndex((prev) => Math.max(prev - 1, -1));
119 } else if (e.key === "Enter" && selectedIndex >= 0) {
120 e.preventDefault();
121 selectSuggestion(suggestions[selectedIndex]);
122 } else if (e.key === "Escape") {
123 setShowSuggestions(false);
124 }
125 };
126
127 const selectSuggestion = (actor: ActorSearchItem) => {
128 isSelectionRef.current = true;
129 setHandle(actor.handle);
130 setSelectedAvatar(actor.avatar || null);
131 setSuggestions([]);
132 setShowSuggestions(false);
133 inputRef.current?.blur();
134 };
135
136 const handleSubmit = async (e: React.FormEvent) => {
137 e.preventDefault();
138 if (!handle.trim()) return;
139
140 setLoading(true);
141 setError(null);
142
143 try {
144 analytics.capture("login_initiated", { handle: handle.trim() });
145 const result = await startLogin(handle.trim());
146 if (result.authorizationUrl) {
147 const url = new URL(result.authorizationUrl);
148 if (url.protocol !== "https:")
149 throw new Error("Invalid authorization URL");
150 window.location.href = result.authorizationUrl;
151 }
152 } catch (err) {
153 const message = err instanceof Error ? err.message : "Unknown error";
154 analytics.captureException(err);
155 setError(message || "Failed to initiate login. Please try again.");
156 setLoading(false);
157 }
158 };
159
160 if (initialError === "banned") {
161 return (
162 <div className="relative min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4 overflow-hidden">
163 <div className="pointer-events-none absolute inset-0 -z-0">
164 <div className="absolute top-1/4 left-1/2 -translate-x-1/2 h-96 w-96 rounded-full bg-red-200/30 dark:bg-red-900/20 blur-3xl" />
165 </div>
166 <div className="relative w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none text-center">
167 <div className="flex justify-center mb-5">
168 <div className="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
169 <ShieldOff size={28} className="text-red-500" />
170 </div>
171 </div>
172 <h1 className="text-xl font-bold font-display text-surface-900 dark:text-white mb-2">
173 {t("login.bannedTitle")}
174 </h1>
175 <p className="text-sm text-surface-500 dark:text-surface-400 mb-1 leading-relaxed">
176 {t("login.bannedMessage")}
177 </p>
178 <p className="text-sm text-surface-500 dark:text-surface-400 mb-6 leading-relaxed">
179 {t("login.bannedAppeal")}{" "}
180 <a
181 href="mailto:hello@margin.at"
182 className="text-[#027bff] hover:underline font-medium"
183 >
184 hello@margin.at
185 </a>
186 .
187 </p>
188 <button
189 onClick={async () => {
190 await fetch("/auth/logout", { method: "POST" }).catch(() => {});
191 window.location.href = "/login";
192 }}
193 className="w-full py-3 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-xl font-semibold transition-all text-sm"
194 >
195 {t("login.bannedSignOut")}
196 </button>
197 </div>
198 </div>
199 );
200 }
201
202 return (
203 <div className="relative min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4 overflow-hidden">
204 <div className="pointer-events-none absolute inset-0 -z-0">
205 <div className="absolute top-1/4 left-1/2 -translate-x-1/2 h-96 w-96 rounded-full bg-primary-200/30 dark:bg-primary-900/20 blur-3xl" />
206 </div>
207 <div className="relative w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none">
208 <div className="flex flex-col items-center mb-8">
209 <h1 className="text-2xl font-bold font-display text-surface-900 dark:text-white text-center leading-snug">
210 {t("login.signInWith")} <br />
211 <span
212 className={`inline-block transition-all duration-400 ease-out text-transparent bg-clip-text bg-gradient-to-r from-[#027bff] to-[#0285FF] ${morphClass}`}
213 >
214 {providers[providerIndex]}
215 </span>{" "}
216 {t("login.handleSuffix")}
217 </h1>
218 </div>
219
220 <form onSubmit={handleSubmit} className="w-full flex flex-col gap-4">
221 <div className="relative group">
222 <div className="absolute left-4 top-1/2 -translate-y-1/2 text-surface-400 dark:text-surface-500 transition-colors pointer-events-none">
223 {selectedAvatar ? (
224 <Avatar
225 src={selectedAvatar}
226 size="xs"
227 className="ring-2 ring-white dark:ring-surface-900 shadow-sm"
228 />
229 ) : (
230 <AtSign
231 size={20}
232 className="stroke-[2.5] group-focus-within:text-[#027bff]"
233 />
234 )}
235 </div>
236 <input
237 ref={inputRef}
238 type="text"
239 value={handle}
240 onChange={(e) => {
241 const val = e.target.value;
242 setHandle(val);
243 if (selectedAvatar) setSelectedAvatar(null);
244 if (val.length < 3) {
245 setSuggestions([]);
246 setShowSuggestions(false);
247 }
248 }}
249 onKeyDown={handleKeyDown}
250 onFocus={() =>
251 handle.length >= 3 &&
252 suggestions.length > 0 &&
253 !handle.includes(".") &&
254 setShowSuggestions(true)
255 }
256 placeholder={t("login.handlePlaceholder")}
257 className="w-full pl-12 pr-4 py-3.5 bg-surface-50 dark:bg-surface-950 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-[#027bff] dark:focus:border-[#027bff] outline-none focus:ring-4 focus:ring-[#027bff]/10 transition-all font-medium text-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500"
258 autoCapitalize="none"
259 autoCorrect="off"
260 autoComplete="off"
261 spellCheck={false}
262 disabled={loading}
263 />
264
265 {showSuggestions && suggestions.length > 0 && (
266 <div
267 ref={suggestionsRef}
268 className="absolute top-[calc(100%+8px)] left-0 right-0 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in max-h-[300px] overflow-y-auto"
269 >
270 {suggestions.map((actor, index) => (
271 <button
272 key={actor.did}
273 type="button"
274 className={`w-full flex items-center gap-3 px-4 py-3 border-b border-surface-100 dark:border-surface-800 last:border-0 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors text-left ${index === selectedIndex ? "bg-surface-50 dark:bg-surface-800" : ""}`}
275 onClick={() => selectSuggestion(actor)}
276 >
277 <Avatar src={actor.avatar} size="sm" />
278 <div className="min-w-0">
279 <div className="font-semibold text-surface-900 dark:text-white truncate text-sm">
280 {actor.displayName || actor.handle}
281 </div>
282 <div className="text-surface-500 dark:text-surface-400 text-xs truncate">
283 @{actor.handle}
284 </div>
285 </div>
286 </button>
287 ))}
288 </div>
289 )}
290 </div>
291
292 {error && (
293 <div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg border border-red-100 dark:border-red-800 text-center font-medium animate-fade-in">
294 {error}
295 </div>
296 )}
297
298 <button
299 type="submit"
300 disabled={loading || !handle}
301 className="w-full py-3.5 bg-[#027bff] hover:bg-[#0269d9] text-white rounded-xl font-semibold text-base tracking-wide disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2 mt-2"
302 >
303 {loading ? t("login.connecting") : t("login.continue")}
304 </button>
305
306 <p className="text-center text-sm text-surface-400 dark:text-surface-500 mt-2 leading-relaxed">
307 {t("login.termsPrefix")}{" "}
308 <a
309 href="/terms"
310 className="text-surface-900 dark:text-white hover:underline font-medium hover:text-[#027bff] dark:hover:text-[#027bff] transition-colors"
311 >
312 {t("login.termsLink")}
313 </a>{" "}
314 {t("login.termsAnd")}{" "}
315 <a
316 href="/privacy"
317 className="text-surface-900 dark:text-white hover:underline font-medium hover:text-[#027bff] dark:hover:text-[#027bff] transition-colors"
318 >
319 {t("login.privacyLink")}
320 </a>
321 </p>
322
323 <div className="flex items-center justify-center py-1">
324 <span className="text-xs text-surface-300 dark:text-surface-600">
325 {t("login.or")}
326 </span>
327 </div>
328
329 <button
330 type="button"
331 onClick={() => setShowSignUp(true)}
332 className="w-full py-2.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white bg-surface-50 dark:bg-surface-800/60 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-colors"
333 >
334 {t("login.createAccount")}
335 </button>
336 </form>
337 </div>
338
339 {showSignUp && <SignUpModal onClose={() => setShowSignUp(false)} />}
340 </div>
341 );
342}