(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at frontend-rewrite 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}