Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect, useMemo } from "react";
2import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react";
3import {
4 BlackskyIcon,
5 NorthskyIcon,
6 BlueskyIcon,
7 TophhieIcon,
8 MarginIcon,
9} from "../common/Icons";
10import { startSignup } from "../../api/client";
11
12interface Provider {
13 id: string;
14 name: string;
15 service: string;
16 Icon: React.ComponentType<{ size?: number }> | null;
17 description: string;
18 custom?: boolean;
19 wide?: boolean;
20}
21
22const MARGIN_PROVIDER: Provider = {
23 id: "margin",
24 name: "Margin",
25 service: "https://margin.cafe",
26 Icon: MarginIcon,
27 description: "Hosted by Margin, the easiest way to get started",
28};
29
30const OTHER_PROVIDERS: Provider[] = [
31 {
32 id: "bluesky",
33 name: "Bluesky",
34 service: "https://bsky.social",
35 Icon: BlueskyIcon,
36 description: "The most popular option on the AT Protocol",
37 },
38 {
39 id: "blacksky",
40 name: "Blacksky",
41 service: "https://blacksky.app",
42 Icon: BlackskyIcon,
43 description: "For the Culture. A safe space for users and allies",
44 },
45 {
46 id: "selfhosted.social",
47 name: "selfhosted.social",
48 service: "https://selfhosted.social",
49 Icon: null,
50 description: "For hackers, designers, and ATProto enthusiasts.",
51 },
52 {
53 id: "northsky",
54 name: "Northsky",
55 service: "https://northsky.social",
56 Icon: NorthskyIcon,
57 description: "A Canadian-based worker-owned cooperative",
58 },
59 {
60 id: "tophhie",
61 name: "Tophhie",
62 service: "https://tophhie.social",
63 Icon: TophhieIcon,
64 description: "A welcoming and friendly community",
65 },
66 {
67 id: "altq",
68 name: "AltQ",
69 service: "https://altq.net",
70 Icon: null,
71 description: "An independent, self-hosted PDS instance",
72 },
73 {
74 id: "custom",
75 name: "Custom",
76 service: "",
77 custom: true,
78 Icon: null,
79 description: "Connect to your own or another custom PDS",
80 },
81];
82
83function shuffleArray<T>(arr: T[]): T[] {
84 const shuffled = [...arr];
85 for (let i = shuffled.length - 1; i > 0; i--) {
86 const j = Math.floor(Math.random() * (i + 1));
87 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
88 }
89 return shuffled;
90}
91
92const inviteStatusPromise: Promise<Record<string, boolean>> = (async () => {
93 const results: Record<string, boolean> = {};
94 await Promise.allSettled(
95 [MARGIN_PROVIDER, ...OTHER_PROVIDERS]
96 .filter((p) => p.service && !p.custom)
97 .map(async (p) => {
98 try {
99 const res = await fetch(
100 `${p.service}/xrpc/com.atproto.server.describeServer`,
101 );
102 if (res.ok) {
103 const data = await res.json();
104 results[p.id] = !!data.inviteCodeRequired;
105 }
106 } catch {
107 // ignore unreachable providers
108 }
109 }),
110 );
111 return results;
112})();
113
114interface SignUpModalProps {
115 onClose: () => void;
116}
117
118export default function SignUpModal({ onClose }: SignUpModalProps) {
119 const [showCustomInput, setShowCustomInput] = useState(false);
120 const [customService, setCustomService] = useState("");
121 const [loading, setLoading] = useState(false);
122 const [error, setError] = useState<string | null>(null);
123 const [inviteStatus, setInviteStatus] = useState<Record<string, boolean>>({});
124 const [statusLoaded, setStatusLoaded] = useState(false);
125
126 useEffect(() => {
127 inviteStatusPromise.then((status) => {
128 setInviteStatus(status);
129 setStatusLoaded(true);
130 });
131 }, []);
132
133 const providers = useMemo(() => {
134 const nonCustom = OTHER_PROVIDERS.filter((p) => !p.custom);
135 const custom = OTHER_PROVIDERS.find((p) => p.custom);
136
137 if (!statusLoaded) {
138 return [
139 MARGIN_PROVIDER,
140 ...shuffleArray(nonCustom),
141 ...(custom ? [custom] : []),
142 ];
143 }
144
145 const open = nonCustom.filter((p) => !inviteStatus[p.id]);
146 const inviteOnly = nonCustom.filter((p) => inviteStatus[p.id]);
147 return [
148 MARGIN_PROVIDER,
149 ...shuffleArray(open),
150 ...shuffleArray(inviteOnly),
151 ...(custom ? [custom] : []),
152 ];
153 }, [statusLoaded, inviteStatus]);
154
155 useEffect(() => {
156 document.body.style.overflow = "hidden";
157 return () => {
158 document.body.style.overflow = "unset";
159 };
160 }, []);
161
162 const handleProviderSelect = async (provider: Provider) => {
163 if (provider.custom) {
164 setShowCustomInput(true);
165 return;
166 }
167
168 setLoading(true);
169 setError(null);
170
171 try {
172 const result = await startSignup(provider.service);
173 if (result.authorizationUrl) {
174 window.location.assign(result.authorizationUrl);
175 }
176 } catch (err) {
177 console.error(err);
178 setError("Could not connect to this provider. Please try again.");
179 setLoading(false);
180 }
181 };
182
183 const handleCustomSubmit = async (e: React.FormEvent) => {
184 e.preventDefault();
185 if (!customService.trim()) return;
186
187 setLoading(true);
188 setError(null);
189
190 let serviceUrl = customService.trim();
191 if (!serviceUrl.startsWith("http")) {
192 serviceUrl = `https://${serviceUrl}`;
193 }
194
195 try {
196 const result = await startSignup(serviceUrl);
197 if (result.authorizationUrl) {
198 window.location.href = result.authorizationUrl;
199 }
200 } catch (err) {
201 console.error(err);
202 setError("Could not connect to this PDS. Please check the URL.");
203 setLoading(false);
204 }
205 };
206
207 return (
208 <div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
209 <div className="w-full sm:max-w-md bg-white dark:bg-surface-900 rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden animate-slide-up max-h-[90vh] sm:max-h-[85vh] flex flex-col">
210 <div className="p-3 sm:p-4 flex justify-end flex-shrink-0">
211 <button
212 onClick={onClose}
213 className="p-2 text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors"
214 >
215 <X size={20} />
216 </button>
217 </div>
218
219 <div className="px-5 sm:px-8 pb-8 sm:pb-10 overflow-y-auto">
220 {loading ? (
221 <div className="text-center py-10">
222 <Loader2
223 size={40}
224 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-4"
225 />
226 <p className="text-surface-600 dark:text-surface-400 font-medium">
227 Connecting to provider...
228 </p>
229 </div>
230 ) : showCustomInput ? (
231 <div>
232 <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-6">
233 Custom Provider
234 </h2>
235 <form onSubmit={handleCustomSubmit} className="space-y-4">
236 <div>
237 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
238 PDS address (e.g. pds.example.com)
239 </label>
240 <input
241 type="text"
242 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 dark:focus:ring-primary-400/10 outline-none transition-all"
243 value={customService}
244 onChange={(e) => setCustomService(e.target.value)}
245 placeholder="pds.example.com"
246 autoFocus
247 />
248 </div>
249
250 {error && (
251 <div className="p-3 bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 text-sm rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900/40">
252 <AlertCircle size={16} />
253 {error}
254 </div>
255 )}
256
257 <div className="flex gap-3 pt-4">
258 <button
259 type="button"
260 className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-300 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors"
261 onClick={() => {
262 setShowCustomInput(false);
263 setError(null);
264 }}
265 >
266 Back
267 </button>
268 <button
269 type="submit"
270 className="flex-1 py-3 bg-primary-600 dark:bg-primary-500 text-white font-semibold rounded-xl hover:bg-primary-700 dark:hover:bg-primary-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
271 disabled={!customService.trim()}
272 >
273 Continue
274 </button>
275 </div>
276 </form>
277 </div>
278 ) : (
279 <div>
280 <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-2">
281 Create your account
282 </h2>
283 <p className="text-surface-500 dark:text-surface-400 mb-6">
284 Margin adheres to the{" "}
285 <a
286 href="https://atproto.com"
287 target="_blank"
288 rel="noopener noreferrer"
289 className="text-primary-600 dark:text-primary-400 hover:underline"
290 >
291 AT Protocol
292 </a>
293 . Choose a provider to host your account.
294 </p>
295
296 {error && (
297 <div className="mb-4 p-3 bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 text-sm rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900/40">
298 <AlertCircle size={16} />
299 {error}
300 </div>
301 )}
302
303 <div className="space-y-2">
304 {providers.map((p) => (
305 <button
306 key={p.id}
307 className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left group ${
308 p.id === "margin"
309 ? "bg-primary-50/80 dark:bg-primary-900/20 border border-primary-200/60 dark:border-primary-800/40 hover:border-primary-300 dark:hover:border-primary-700"
310 : "bg-surface-50 dark:bg-surface-800/60 hover:bg-surface-100 dark:hover:bg-surface-800 border border-transparent"
311 }`}
312 onClick={() => handleProviderSelect(p)}
313 >
314 <div
315 className={`w-9 h-9 flex items-center justify-center rounded-full flex-shrink-0 ${
316 p.id === "margin"
317 ? "bg-primary-100 dark:bg-primary-900/40 text-primary-600 dark:text-primary-400"
318 : "bg-white dark:bg-surface-700 shadow-sm dark:shadow-none text-surface-600 dark:text-surface-300"
319 }`}
320 >
321 {p.Icon ? (
322 <p.Icon size={18} />
323 ) : (
324 <span className="font-bold text-xs">{p.name[0]}</span>
325 )}
326 </div>
327 <div className="flex-1 min-w-0">
328 <h3 className="text-sm font-bold text-surface-900 dark:text-white">
329 {p.name}
330 </h3>
331 <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1">
332 {p.description}
333 </p>
334 </div>
335 {inviteStatus[p.id] && (
336 <span className="text-[10px] font-medium text-surface-400 dark:text-surface-500 bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded-md flex-shrink-0">
337 Invite
338 </span>
339 )}
340 <ChevronRight
341 size={16}
342 className="text-surface-300 dark:text-surface-600 group-hover:text-surface-600 dark:group-hover:text-surface-400"
343 />
344 </button>
345 ))}
346 </div>
347 </div>
348 )}
349 </div>
350 </div>
351 </div>
352 );
353}