Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at 92fc8b21b443c2bb257fbced7ca5b8ab46037f62 394 lines 12 kB view raw
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 &apos;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}