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 {
4 BlackskyIcon,
5 NorthskyIcon,
6 BlueskyIcon,
7 TophhieIcon,
8 MarginIcon,
9} from "./Icons";
10import { startSignup } from "../api/client";
11
12const RECOMMENDED_PROVIDER = {
13 id: "margin",
14 name: "Margin",
15 service: "https://margin.cafe",
16 Icon: MarginIcon,
17 description: "Hosted by Margin, the easiest way to get started",
18};
19
20const OTHER_PROVIDERS = [
21 {
22 id: "bluesky",
23 name: "Bluesky",
24 service: "https://bsky.social",
25 Icon: BlueskyIcon,
26 description: "The most popular option on the AT Protocol",
27 },
28 {
29 id: "blacksky",
30 name: "Blacksky",
31 service: "https://blacksky.app",
32 Icon: BlackskyIcon,
33 description: "For the Culture. A safe space for Black users and allies",
34 },
35 {
36 id: "selfhosted.social",
37 name: "selfhosted.social",
38 service: "https://selfhosted.social",
39 Icon: null,
40 description:
41 "For hackers, designers, developers, ATProto enthusiasts, scrobblers, tinkerers, friends, and curious minds.",
42 },
43 {
44 id: "northsky",
45 name: "Northsky",
46 service: "https://northsky.social",
47 Icon: NorthskyIcon,
48 description: "A Canadian-based worker-owned cooperative",
49 },
50 {
51 id: "tophhie",
52 name: "Tophhie",
53 service: "https://tophhie.social",
54 Icon: TophhieIcon,
55 description: "A welcoming and friendly community",
56 },
57 {
58 id: "altq",
59 name: "AltQ",
60 service: "https://altq.net",
61 Icon: null,
62 description: "An independent, self-hosted PDS instance",
63 },
64 {
65 id: "custom",
66 name: "Custom",
67 service: "",
68 custom: true,
69 Icon: null,
70 description: "Connect to your own or another custom PDS",
71 },
72];
73
74export default function SignUpModal({ onClose }) {
75 const [showOtherProviders, setShowOtherProviders] = useState(false);
76 const [showCustomInput, setShowCustomInput] = useState(false);
77 const [customService, setCustomService] = useState("");
78 const [loading, setLoading] = useState(false);
79 const [error, setError] = useState(null);
80
81 useEffect(() => {
82 document.body.style.overflow = "hidden";
83 return () => {
84 document.body.style.overflow = "unset";
85 };
86 }, []);
87
88 const handleProviderSelect = async (provider) => {
89 if (provider.custom) {
90 setShowCustomInput(true);
91 return;
92 }
93
94 setLoading(true);
95 setError(null);
96
97 try {
98 const result = await startSignup(provider.service);
99 if (result.authorizationUrl) {
100 window.location.href = result.authorizationUrl;
101 }
102 } catch (err) {
103 console.error(err);
104 setError("Could not connect to this provider. Please try again.");
105 setLoading(false);
106 }
107 };
108
109 const handleCustomSubmit = async (e) => {
110 e.preventDefault();
111 if (!customService.trim()) return;
112
113 setLoading(true);
114 setError(null);
115
116 let serviceUrl = customService.trim();
117 if (!serviceUrl.startsWith("http")) {
118 serviceUrl = `https://${serviceUrl}`;
119 }
120
121 try {
122 const result = await startSignup(serviceUrl);
123 if (result.authorizationUrl) {
124 window.location.href = result.authorizationUrl;
125 }
126 } catch (err) {
127 console.error(err);
128 setError("Could not connect to this PDS. Please check the URL.");
129 setLoading(false);
130 }
131 };
132
133 return (
134 <div className="modal-overlay">
135 <div className="modal-content signup-modal">
136 <button className="modal-close" onClick={onClose}>
137 <X size={20} />
138 </button>
139
140 {loading ? (
141 <div className="signup-step" style={{ textAlign: "center" }}>
142 <Loader2 size={32} className="spinner" />
143 <p style={{ marginTop: "1rem", color: "var(--text-secondary)" }}>
144 Connecting to provider...
145 </p>
146 </div>
147 ) : showCustomInput ? (
148 <div className="signup-step">
149 <h2>Custom Provider</h2>
150 <form onSubmit={handleCustomSubmit}>
151 <div className="form-group">
152 <label className="form-label">
153 PDS address (e.g. pds.example.com)
154 </label>
155 <input
156 type="text"
157 className="form-input"
158 value={customService}
159 onChange={(e) => setCustomService(e.target.value)}
160 placeholder="pds.example.com"
161 autoFocus
162 />
163 </div>
164
165 {error && (
166 <div className="error-message">
167 <AlertCircle size={16} />
168 {error}
169 </div>
170 )}
171
172 <div className="modal-actions">
173 <button
174 type="button"
175 className="btn btn-secondary"
176 onClick={() => {
177 setShowCustomInput(false);
178 setError(null);
179 }}
180 >
181 Back
182 </button>
183 <button
184 type="submit"
185 className="btn btn-primary"
186 disabled={!customService.trim()}
187 >
188 Continue
189 </button>
190 </div>
191 </form>
192 </div>
193 ) : (
194 <div className="signup-step">
195 <h2>Create your account</h2>
196 <p className="signup-subtitle">
197 Margin uses the AT Protocol, the same decentralized network that
198 powers Bluesky. Your account will be hosted on a server of your
199 choice.
200 </p>
201
202 {error && (
203 <div className="error-message" style={{ marginBottom: "1rem" }}>
204 <AlertCircle size={16} />
205 {error}
206 </div>
207 )}
208
209 <div className="signup-recommended">
210 <div className="signup-recommended-badge">Recommended</div>
211 <button
212 className="provider-card provider-card-featured"
213 onClick={() => handleProviderSelect(RECOMMENDED_PROVIDER)}
214 >
215 <div className="provider-icon">
216 <RECOMMENDED_PROVIDER.Icon size={24} />
217 </div>
218 <div className="provider-info">
219 <h3>{RECOMMENDED_PROVIDER.name}</h3>
220 <span>{RECOMMENDED_PROVIDER.description}</span>
221 </div>
222 <ChevronRight size={16} className="provider-arrow" />
223 </button>
224 </div>
225
226 <button
227 type="button"
228 className="signup-toggle-others"
229 onClick={() => setShowOtherProviders(!showOtherProviders)}
230 >
231 {showOtherProviders ? "Hide other options" : "More options"}
232 <ChevronRight
233 size={14}
234 className={`toggle-chevron ${showOtherProviders ? "open" : ""}`}
235 />
236 </button>
237
238 {showOtherProviders && (
239 <div className="provider-grid">
240 {OTHER_PROVIDERS.map((p) => (
241 <button
242 key={p.id}
243 className="provider-card"
244 onClick={() => handleProviderSelect(p)}
245 >
246 <div className={`provider-icon ${p.wide ? "wide" : ""}`}>
247 {p.Icon ? (
248 <p.Icon size={32} />
249 ) : (
250 <span className="provider-initial">{p.name[0]}</span>
251 )}
252 </div>
253 <div className="provider-info">
254 <h3>{p.name}</h3>
255 <span>{p.description}</span>
256 </div>
257 <ChevronRight size={16} className="provider-arrow" />
258 </button>
259 ))}
260 </div>
261 )}
262 </div>
263 )}
264 </div>
265 </div>
266 );
267}