Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 353 lines 13 kB view raw
1import React, { useState, useEffect, useMemo } from "react"; 2import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3import { 4 BlackskyIcon, 5 NorthskyIcon, 6 BlueskyIcon, 7 TophhieIcon, 8 MarginIcon, 9} from "../common/Icons"; 10import { startSignup } from "../../api/client"; 11 12interface Provider { 13 id: string; 14 name: string; 15 service: string; 16 Icon: React.ComponentType<{ size?: number }> | null; 17 description: string; 18 custom?: boolean; 19 wide?: boolean; 20} 21 22const MARGIN_PROVIDER: Provider = { 23 id: "margin", 24 name: "Margin", 25 service: "https://margin.cafe", 26 Icon: MarginIcon, 27 description: "Hosted by Margin, the easiest way to get started", 28}; 29 30const OTHER_PROVIDERS: Provider[] = [ 31 { 32 id: "bluesky", 33 name: "Bluesky", 34 service: "https://bsky.social", 35 Icon: BlueskyIcon, 36 description: "The most popular option on the AT Protocol", 37 }, 38 { 39 id: "blacksky", 40 name: "Blacksky", 41 service: "https://blacksky.app", 42 Icon: BlackskyIcon, 43 description: "For the Culture. A safe space for users and allies", 44 }, 45 { 46 id: "selfhosted.social", 47 name: "selfhosted.social", 48 service: "https://selfhosted.social", 49 Icon: null, 50 description: "For hackers, designers, and ATProto enthusiasts.", 51 }, 52 { 53 id: "northsky", 54 name: "Northsky", 55 service: "https://northsky.social", 56 Icon: NorthskyIcon, 57 description: "A Canadian-based worker-owned cooperative", 58 }, 59 { 60 id: "tophhie", 61 name: "Tophhie", 62 service: "https://tophhie.social", 63 Icon: TophhieIcon, 64 description: "A welcoming and friendly community", 65 }, 66 { 67 id: "altq", 68 name: "AltQ", 69 service: "https://altq.net", 70 Icon: null, 71 description: "An independent, self-hosted PDS instance", 72 }, 73 { 74 id: "custom", 75 name: "Custom", 76 service: "", 77 custom: true, 78 Icon: null, 79 description: "Connect to your own or another custom PDS", 80 }, 81]; 82 83function shuffleArray<T>(arr: T[]): T[] { 84 const shuffled = [...arr]; 85 for (let i = shuffled.length - 1; i > 0; i--) { 86 const j = Math.floor(Math.random() * (i + 1)); 87 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 88 } 89 return shuffled; 90} 91 92const inviteStatusPromise: Promise<Record<string, boolean>> = (async () => { 93 const results: Record<string, boolean> = {}; 94 await Promise.allSettled( 95 [MARGIN_PROVIDER, ...OTHER_PROVIDERS] 96 .filter((p) => p.service && !p.custom) 97 .map(async (p) => { 98 try { 99 const res = await fetch( 100 `${p.service}/xrpc/com.atproto.server.describeServer`, 101 ); 102 if (res.ok) { 103 const data = await res.json(); 104 results[p.id] = !!data.inviteCodeRequired; 105 } 106 } catch { 107 // ignore unreachable providers 108 } 109 }), 110 ); 111 return results; 112})(); 113 114interface SignUpModalProps { 115 onClose: () => void; 116} 117 118export default function SignUpModal({ onClose }: SignUpModalProps) { 119 const [showCustomInput, setShowCustomInput] = useState(false); 120 const [customService, setCustomService] = useState(""); 121 const [loading, setLoading] = useState(false); 122 const [error, setError] = useState<string | null>(null); 123 const [inviteStatus, setInviteStatus] = useState<Record<string, boolean>>({}); 124 const [statusLoaded, setStatusLoaded] = useState(false); 125 126 useEffect(() => { 127 inviteStatusPromise.then((status) => { 128 setInviteStatus(status); 129 setStatusLoaded(true); 130 }); 131 }, []); 132 133 const providers = useMemo(() => { 134 const nonCustom = OTHER_PROVIDERS.filter((p) => !p.custom); 135 const custom = OTHER_PROVIDERS.find((p) => p.custom); 136 137 if (!statusLoaded) { 138 return [ 139 MARGIN_PROVIDER, 140 ...shuffleArray(nonCustom), 141 ...(custom ? [custom] : []), 142 ]; 143 } 144 145 const open = nonCustom.filter((p) => !inviteStatus[p.id]); 146 const inviteOnly = nonCustom.filter((p) => inviteStatus[p.id]); 147 return [ 148 MARGIN_PROVIDER, 149 ...shuffleArray(open), 150 ...shuffleArray(inviteOnly), 151 ...(custom ? [custom] : []), 152 ]; 153 }, [statusLoaded, inviteStatus]); 154 155 useEffect(() => { 156 document.body.style.overflow = "hidden"; 157 return () => { 158 document.body.style.overflow = "unset"; 159 }; 160 }, []); 161 162 const handleProviderSelect = async (provider: Provider) => { 163 if (provider.custom) { 164 setShowCustomInput(true); 165 return; 166 } 167 168 setLoading(true); 169 setError(null); 170 171 try { 172 const result = await startSignup(provider.service); 173 if (result.authorizationUrl) { 174 window.location.assign(result.authorizationUrl); 175 } 176 } catch (err) { 177 console.error(err); 178 setError("Could not connect to this provider. Please try again."); 179 setLoading(false); 180 } 181 }; 182 183 const handleCustomSubmit = async (e: React.FormEvent) => { 184 e.preventDefault(); 185 if (!customService.trim()) return; 186 187 setLoading(true); 188 setError(null); 189 190 let serviceUrl = customService.trim(); 191 if (!serviceUrl.startsWith("http")) { 192 serviceUrl = `https://${serviceUrl}`; 193 } 194 195 try { 196 const result = await startSignup(serviceUrl); 197 if (result.authorizationUrl) { 198 window.location.href = result.authorizationUrl; 199 } 200 } catch (err) { 201 console.error(err); 202 setError("Could not connect to this PDS. Please check the URL."); 203 setLoading(false); 204 } 205 }; 206 207 return ( 208 <div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm animate-fade-in"> 209 <div className="w-full sm:max-w-md bg-white dark:bg-surface-900 rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden animate-slide-up max-h-[90vh] sm:max-h-[85vh] flex flex-col"> 210 <div className="p-3 sm:p-4 flex justify-end flex-shrink-0"> 211 <button 212 onClick={onClose} 213 className="p-2 text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors" 214 > 215 <X size={20} /> 216 </button> 217 </div> 218 219 <div className="px-5 sm:px-8 pb-8 sm:pb-10 overflow-y-auto"> 220 {loading ? ( 221 <div className="text-center py-10"> 222 <Loader2 223 size={40} 224 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-4" 225 /> 226 <p className="text-surface-600 dark:text-surface-400 font-medium"> 227 Connecting to provider... 228 </p> 229 </div> 230 ) : showCustomInput ? ( 231 <div> 232 <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-6"> 233 Custom Provider 234 </h2> 235 <form onSubmit={handleCustomSubmit} className="space-y-4"> 236 <div> 237 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 238 PDS address (e.g. pds.example.com) 239 </label> 240 <input 241 type="text" 242 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 dark:focus:ring-primary-400/10 outline-none transition-all" 243 value={customService} 244 onChange={(e) => setCustomService(e.target.value)} 245 placeholder="pds.example.com" 246 autoFocus 247 /> 248 </div> 249 250 {error && ( 251 <div className="p-3 bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 text-sm rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900/40"> 252 <AlertCircle size={16} /> 253 {error} 254 </div> 255 )} 256 257 <div className="flex gap-3 pt-4"> 258 <button 259 type="button" 260 className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-300 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" 261 onClick={() => { 262 setShowCustomInput(false); 263 setError(null); 264 }} 265 > 266 Back 267 </button> 268 <button 269 type="submit" 270 className="flex-1 py-3 bg-primary-600 dark:bg-primary-500 text-white font-semibold rounded-xl hover:bg-primary-700 dark:hover:bg-primary-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 271 disabled={!customService.trim()} 272 > 273 Continue 274 </button> 275 </div> 276 </form> 277 </div> 278 ) : ( 279 <div> 280 <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-2"> 281 Create your account 282 </h2> 283 <p className="text-surface-500 dark:text-surface-400 mb-6"> 284 Margin adheres to the{" "} 285 <a 286 href="https://atproto.com" 287 target="_blank" 288 rel="noopener noreferrer" 289 className="text-primary-600 dark:text-primary-400 hover:underline" 290 > 291 AT Protocol 292 </a> 293 . Choose a provider to host your account. 294 </p> 295 296 {error && ( 297 <div className="mb-4 p-3 bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 text-sm rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900/40"> 298 <AlertCircle size={16} /> 299 {error} 300 </div> 301 )} 302 303 <div className="space-y-2"> 304 {providers.map((p) => ( 305 <button 306 key={p.id} 307 className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left group ${ 308 p.id === "margin" 309 ? "bg-primary-50/80 dark:bg-primary-900/20 border border-primary-200/60 dark:border-primary-800/40 hover:border-primary-300 dark:hover:border-primary-700" 310 : "bg-surface-50 dark:bg-surface-800/60 hover:bg-surface-100 dark:hover:bg-surface-800 border border-transparent" 311 }`} 312 onClick={() => handleProviderSelect(p)} 313 > 314 <div 315 className={`w-9 h-9 flex items-center justify-center rounded-full flex-shrink-0 ${ 316 p.id === "margin" 317 ? "bg-primary-100 dark:bg-primary-900/40 text-primary-600 dark:text-primary-400" 318 : "bg-white dark:bg-surface-700 shadow-sm dark:shadow-none text-surface-600 dark:text-surface-300" 319 }`} 320 > 321 {p.Icon ? ( 322 <p.Icon size={18} /> 323 ) : ( 324 <span className="font-bold text-xs">{p.name[0]}</span> 325 )} 326 </div> 327 <div className="flex-1 min-w-0"> 328 <h3 className="text-sm font-bold text-surface-900 dark:text-white"> 329 {p.name} 330 </h3> 331 <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1"> 332 {p.description} 333 </p> 334 </div> 335 {inviteStatus[p.id] && ( 336 <span className="text-[10px] font-medium text-surface-400 dark:text-surface-500 bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded-md flex-shrink-0"> 337 Invite 338 </span> 339 )} 340 <ChevronRight 341 size={16} 342 className="text-surface-300 dark:text-surface-600 group-hover:text-surface-600 dark:group-hover:text-surface-400" 343 /> 344 </button> 345 ))} 346 </div> 347 </div> 348 )} 349 </div> 350 </div> 351 </div> 352 ); 353}