Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect } from "react";
2import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react";
3import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons";
4import { describeServer, createAccount, startLogin } from "../api/client";
5
6const PROVIDERS = [
7 {
8 id: "bluesky",
9 name: "Bluesky",
10 service: "https://bsky.social",
11 Icon: BlueskyIcon,
12 description: "The main network",
13 },
14 {
15 id: "blacksky",
16 name: "Blacksky",
17 service: "https://blacksky.app",
18 Icon: BlackskyIcon,
19 description: "For the Culture. A safe space for Black users and allies",
20 },
21 {
22 id: "northsky",
23 name: "Northsky",
24 service: "https://northsky.social",
25 Icon: NorthskyIcon,
26 description: "A Canadian-based worker-owned cooperative",
27 inviteUrl: "https://northskysocial.com/join",
28 },
29 {
30 id: "topphie",
31 name: "Topphie",
32 service: "https://tophhie.social",
33 Icon: TopphieIcon,
34 description: "A welcoming and friendly community",
35 },
36 {
37 id: "altq",
38 name: "AltQ",
39 service: "https://altq.net",
40 Icon: null,
41 description: "An independent, self-hosted PDS instance",
42 },
43 {
44 id: "selfhosted",
45 name: "Self-Hosted",
46 service: "",
47 custom: true,
48 Icon: null,
49 description: "Connect to your own Personal Data Server",
50 },
51];
52
53export default function SignUpModal({ onClose }) {
54 const [step, setStep] = useState(1);
55 const [selectedProvider, setSelectedProvider] = useState(null);
56 const [customService, setCustomService] = useState("");
57 const [formData, setFormData] = useState({
58 handle: "",
59 email: "",
60 password: "",
61 inviteCode: "",
62 });
63 const [loading, setLoading] = useState(false);
64 const [error, setError] = useState(null);
65 const [serverInfo, setServerInfo] = useState(null);
66
67 useEffect(() => {
68 document.body.style.overflow = "hidden";
69 return () => {
70 document.body.style.overflow = "unset";
71 };
72 }, []);
73
74 const handleProviderSelect = (provider) => {
75 setSelectedProvider(provider);
76 if (!provider.custom) {
77 checkServer(provider.service);
78 } else {
79 setStep(1.5);
80 }
81 };
82
83 const checkServer = async (url) => {
84 setLoading(true);
85 setError(null);
86 try {
87 let serviceUrl = url.trim();
88 if (!serviceUrl.startsWith("http")) {
89 serviceUrl = `https://${serviceUrl}`;
90 }
91
92 const info = await describeServer(serviceUrl);
93 setServerInfo({
94 ...info,
95 service: serviceUrl,
96 inviteCodeRequired: info.inviteCodeRequired ?? true,
97 });
98
99 if (selectedProvider?.custom) {
100 setSelectedProvider({ ...selectedProvider, service: serviceUrl });
101 }
102
103 setStep(2);
104 } catch (err) {
105 console.error(err);
106 setError("Could not connect to this PDS. Please check the URL.");
107 } finally {
108 setLoading(false);
109 }
110 };
111
112 const handleCreateAccount = async (e) => {
113 e.preventDefault();
114 if (!serverInfo) return;
115
116 setLoading(true);
117 setError(null);
118
119 let domain =
120 serverInfo.selectedDomain || serverInfo.availableUserDomains[0];
121 if (!domain.startsWith(".")) {
122 domain = "." + domain;
123 }
124
125 const cleanHandle = formData.handle.trim().replace(/^@/, "");
126 const fullHandle = cleanHandle.endsWith(domain)
127 ? cleanHandle
128 : `${cleanHandle}${domain}`;
129
130 try {
131 await createAccount(serverInfo.service, {
132 handle: fullHandle,
133 email: formData.email,
134 password: formData.password,
135 inviteCode: formData.inviteCode,
136 });
137
138 const result = await startLogin(fullHandle);
139 if (result.authorizationUrl) {
140 window.location.href = result.authorizationUrl;
141 } else {
142 onClose();
143 alert("Account created! Please sign in.");
144 }
145 } catch (err) {
146 setError(err.message || "Failed to create account");
147 setLoading(false);
148 }
149 };
150
151 return (
152 <div className="modal-overlay">
153 <div className="modal-content signup-modal">
154 <button className="modal-close" onClick={onClose}>
155 <X size={20} />
156 </button>
157
158 {step === 1 && (
159 <div className="signup-step">
160 <h2>Choose a Provider</h2>
161 <p className="signup-subtitle">
162 Where would you like to host your account?
163 </p>
164 <div className="provider-grid">
165 {PROVIDERS.map((p) => (
166 <button
167 key={p.id}
168 className="provider-card"
169 onClick={() => handleProviderSelect(p)}
170 >
171 <div className={`provider-icon ${p.wide ? "wide" : ""}`}>
172 {p.Icon ? (
173 <p.Icon size={p.wide ? 32 : 32} />
174 ) : (
175 <span className="provider-initial">{p.name[0]}</span>
176 )}
177 </div>
178 <div className="provider-info">
179 <h3>{p.name}</h3>
180 <span>{p.description}</span>
181 </div>
182 <ChevronRight size={16} className="provider-arrow" />
183 </button>
184 ))}
185 </div>
186 </div>
187 )}
188
189 {step === 1.5 && (
190 <div className="signup-step">
191 <h2>Custom Provider</h2>
192 <form
193 onSubmit={(e) => {
194 e.preventDefault();
195 checkServer(customService);
196 }}
197 >
198 <div className="form-group">
199 <label>PDS address (e.g. pds.example.com)</label>
200 <input
201 type="text"
202 className="login-input"
203 value={customService}
204 onChange={(e) => setCustomService(e.target.value)}
205 placeholder="example.com"
206 autoFocus
207 />
208 </div>
209 {error && (
210 <div className="error-message">
211 <AlertCircle size={14} /> {error}
212 </div>
213 )}
214 <div className="modal-actions">
215 <button
216 type="button"
217 className="btn btn-ghost"
218 onClick={() => setStep(1)}
219 >
220 Back
221 </button>
222 <button
223 type="submit"
224 className="btn btn-primary"
225 disabled={!customService || loading}
226 >
227 {loading ? <Loader2 className="animate-spin" /> : "Next"}
228 </button>
229 </div>
230 </form>
231 </div>
232 )}
233
234 {step === 2 && serverInfo && (
235 <div className="signup-step">
236 <div className="step-header">
237 <button className="btn-back" onClick={() => setStep(1)}>
238 ← Back
239 </button>
240 <h2>
241 Create Account on {selectedProvider?.name || "Custom PDS"}
242 </h2>
243 </div>
244
245 <form onSubmit={handleCreateAccount} className="signup-form">
246 {serverInfo.inviteCodeRequired && (
247 <div className="form-group">
248 <label>Invite Code *</label>
249 <input
250 type="text"
251 className="login-input"
252 value={formData.inviteCode}
253 onChange={(e) =>
254 setFormData({ ...formData, inviteCode: e.target.value })
255 }
256 placeholder="bsky-social-xxxxx"
257 required
258 />
259 {selectedProvider?.inviteUrl && (
260 <p
261 className="legal-text"
262 style={{ textAlign: "left", marginTop: "4px" }}
263 >
264 Need an invite code?{" "}
265 <a
266 href={selectedProvider.inviteUrl}
267 target="_blank"
268 rel="noopener noreferrer"
269 style={{ color: "var(--accent)" }}
270 >
271 Get one here
272 </a>
273 </p>
274 )}
275 </div>
276 )}
277
278 <div className="form-group">
279 <label>Email Address</label>
280 <input
281 type="email"
282 className="login-input"
283 value={formData.email}
284 onChange={(e) =>
285 setFormData({ ...formData, email: e.target.value })
286 }
287 placeholder="you@example.com"
288 required
289 />
290 </div>
291
292 <div className="form-group">
293 <label>Password</label>
294 <input
295 type="password"
296 className="login-input"
297 value={formData.password}
298 onChange={(e) =>
299 setFormData({ ...formData, password: e.target.value })
300 }
301 required
302 />
303 </div>
304
305 <div className="form-group">
306 <label>Handle</label>
307 <div className="handle-input-group">
308 <input
309 type="text"
310 className="login-input"
311 value={formData.handle}
312 onChange={(e) =>
313 setFormData({ ...formData, handle: e.target.value })
314 }
315 placeholder="username"
316 required
317 style={{ flex: 1 }}
318 />
319 {serverInfo.availableUserDomains &&
320 serverInfo.availableUserDomains.length > 1 ? (
321 <select
322 className="login-input"
323 style={{
324 width: "auto",
325 flex: "0 0 auto",
326 paddingRight: "24px",
327 }}
328 onChange={(e) => {
329 setServerInfo({
330 ...serverInfo,
331 selectedDomain: e.target.value,
332 });
333 }}
334 value={
335 serverInfo.selectedDomain ||
336 serverInfo.availableUserDomains[0]
337 }
338 >
339 {serverInfo.availableUserDomains.map((d) => (
340 <option key={d} value={d}>
341 .{d.startsWith(".") ? d.substring(1) : d}
342 </option>
343 ))}
344 </select>
345 ) : (
346 <span className="handle-suffix">
347 {(() => {
348 const d =
349 serverInfo.availableUserDomains?.[0] || "bsky.social";
350 return d.startsWith(".") ? d : `.${d}`;
351 })()}
352 </span>
353 )}
354 </div>
355 </div>
356
357 {error && (
358 <div className="error-message">
359 <AlertCircle size={14} /> {error}
360 </div>
361 )}
362
363 <button
364 type="submit"
365 className="btn btn-primary full-width"
366 disabled={loading}
367 >
368 {loading ? "Creating Account..." : "Create Account"}
369 </button>
370
371 <p className="legal-text">
372 By creating an account, you agree to {selectedProvider?.name}
373 's{" "}
374 {serverInfo.links?.termsOfService ? (
375 <a
376 href={serverInfo.links.termsOfService}
377 target="_blank"
378 rel="noopener noreferrer"
379 style={{ color: "var(--accent)" }}
380 >
381 Terms of Service
382 </a>
383 ) : (
384 "Terms of Service"
385 )}
386 .
387 </p>
388 </form>
389 </div>
390 )}
391 </div>
392 </div>
393 );
394}