Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at 5edfcb084786af01253bffa72de80c6af054eaed 267 lines 8.0 kB view raw
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}