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