Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

better signup flow and margin pds

+356 -309
+1
backend/cmd/server/main.go
··· 94 95 r.Get("/auth/login", oauthHandler.HandleLogin) 96 r.Post("/auth/start", oauthHandler.HandleStart) 97 r.Get("/auth/callback", oauthHandler.HandleCallback) 98 r.Post("/auth/logout", oauthHandler.HandleLogout) 99 r.Get("/auth/session", oauthHandler.HandleSession)
··· 94 95 r.Get("/auth/login", oauthHandler.HandleLogin) 96 r.Post("/auth/start", oauthHandler.HandleStart) 97 + r.Post("/auth/signup", oauthHandler.HandleSignup) 98 r.Get("/auth/callback", oauthHandler.HandleCallback) 99 r.Post("/auth/logout", oauthHandler.HandleLogout) 100 r.Get("/auth/session", oauthHandler.HandleSession)
+19
backend/internal/oauth/client.go
··· 208 return &meta, nil 209 } 210 211 func (c *Client) GeneratePKCE() (verifier, challenge string) { 212 b := make([]byte, 32) 213 rand.Read(b)
··· 208 return &meta, nil 209 } 210 211 + func (c *Client) GetAuthServerMetadataForSignup(ctx context.Context, url string) (*AuthServerMetadata, error) { 212 + url = strings.TrimSuffix(url, "/") 213 + 214 + metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", url) 215 + metaResp, err := http.Get(metaURL) 216 + if err == nil && metaResp.StatusCode == 200 { 217 + defer metaResp.Body.Close() 218 + var meta AuthServerMetadata 219 + if err := json.NewDecoder(metaResp.Body).Decode(&meta); err == nil && meta.Issuer != "" { 220 + return &meta, nil 221 + } 222 + } 223 + if metaResp != nil { 224 + metaResp.Body.Close() 225 + } 226 + 227 + return c.GetAuthServerMetadata(ctx, url) 228 + } 229 + 230 func (c *Client) GeneratePKCE() (verifier, challenge string) { 231 b := make([]byte, 32) 232 rand.Read(b)
+83 -2
backend/internal/oauth/handler.go
··· 283 }) 284 } 285 286 func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { 287 client := h.getDynamicClient(r) 288 ··· 318 } 319 320 ctx := r.Context() 321 - meta, err := client.GetAuthServerMetadata(ctx, pending.PDS) 322 if err != nil { 323 http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError) 324 return 325 } ··· 330 return 331 } 332 333 - if tokenResp.Sub != pending.DID { 334 log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub) 335 http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest) 336 return
··· 283 }) 284 } 285 286 + func (h *Handler) HandleSignup(w http.ResponseWriter, r *http.Request) { 287 + if r.Method != "POST" { 288 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 289 + return 290 + } 291 + 292 + var req struct { 293 + PdsURL string `json:"pds_url"` 294 + } 295 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 296 + http.Error(w, "Invalid request body", http.StatusBadRequest) 297 + return 298 + } 299 + 300 + if req.PdsURL == "" { 301 + http.Error(w, "PDS URL is required", http.StatusBadRequest) 302 + return 303 + } 304 + 305 + client := h.getDynamicClient(r) 306 + ctx := r.Context() 307 + 308 + meta, err := client.GetAuthServerMetadataForSignup(ctx, req.PdsURL) 309 + if err != nil { 310 + log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err) 311 + w.Header().Set("Content-Type", "application/json") 312 + w.WriteHeader(http.StatusBadRequest) 313 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to connect to PDS"}) 314 + return 315 + } 316 + 317 + dpopKey, err := client.GenerateDPoPKey() 318 + if err != nil { 319 + w.Header().Set("Content-Type", "application/json") 320 + w.WriteHeader(http.StatusInternalServerError) 321 + json.NewEncoder(w).Encode(map[string]string{"error": "Internal error"}) 322 + return 323 + } 324 + 325 + pkceVerifier, pkceChallenge := client.GeneratePKCE() 326 + scope := "atproto offline_access blob:* include:at.margin.authFull" 327 + 328 + parResp, state, dpopNonce, err := client.SendPAR(meta, "", scope, dpopKey, pkceChallenge) 329 + if err != nil { 330 + log.Printf("PAR request failed for signup: %v", err) 331 + w.Header().Set("Content-Type", "application/json") 332 + w.WriteHeader(http.StatusInternalServerError) 333 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate signup"}) 334 + return 335 + } 336 + 337 + pending := &PendingAuth{ 338 + State: state, 339 + DID: "", 340 + Handle: "", 341 + PDS: req.PdsURL, 342 + AuthServer: meta.TokenEndpoint, 343 + Issuer: meta.Issuer, 344 + PKCEVerifier: pkceVerifier, 345 + DPoPKey: dpopKey, 346 + DPoPNonce: dpopNonce, 347 + CreatedAt: time.Now(), 348 + } 349 + 350 + h.pendingMu.Lock() 351 + h.pending[state] = pending 352 + h.pendingMu.Unlock() 353 + 354 + authURL, _ := url.Parse(meta.AuthorizationEndpoint) 355 + q := authURL.Query() 356 + q.Set("client_id", client.ClientID) 357 + q.Set("request_uri", parResp.RequestURI) 358 + authURL.RawQuery = q.Encode() 359 + 360 + w.Header().Set("Content-Type", "application/json") 361 + json.NewEncoder(w).Encode(map[string]string{ 362 + "authorizationUrl": authURL.String(), 363 + }) 364 + } 365 + 366 func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { 367 client := h.getDynamicClient(r) 368 ··· 398 } 399 400 ctx := r.Context() 401 + meta, err := client.GetAuthServerMetadataForSignup(ctx, pending.PDS) 402 if err != nil { 403 + log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err) 404 http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError) 405 return 406 } ··· 411 return 412 } 413 414 + if pending.DID != "" && tokenResp.Sub != pending.DID { 415 log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub) 416 http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest) 417 return
+7
web/src/api/client.js
··· 452 body: JSON.stringify({ handle, invite_code: inviteCode }), 453 }); 454 } 455 export async function getTrendingTags(limit = 10) { 456 return request(`${API_BASE}/tags/trending?limit=${limit}`); 457 }
··· 452 body: JSON.stringify({ handle, invite_code: inviteCode }), 453 }); 454 } 455 + 456 + export async function startSignup(pdsUrl) { 457 + return request(`${AUTH_BASE}/signup`, { 458 + method: "POST", 459 + body: JSON.stringify({ pds_url: pdsUrl }), 460 + }); 461 + } 462 export async function getTrendingTags(limit = 10) { 463 return request(`${API_BASE}/tags/trending?limit=${limit}`); 464 }
+117 -255
web/src/components/SignUpModal.jsx
··· 1 import { useState, useEffect } from "react"; 2 import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3 import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons"; 4 - import { describeServer, createAccount, startLogin } from "../api/client"; 5 6 - const PROVIDERS = [ 7 { 8 id: "bluesky", 9 name: "Bluesky", ··· 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", ··· 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 53 export 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"; ··· 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 }; ··· 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>
··· 1 import { useState, useEffect } from "react"; 2 import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3 import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons"; 4 + import { startSignup } from "../api/client"; 5 + import logo from "../assets/logo.svg"; 6 7 + const 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 + 16 + const OTHER_PROVIDERS = [ 17 { 18 id: "bluesky", 19 name: "Bluesky", ··· 34 service: "https://northsky.social", 35 Icon: NorthskyIcon, 36 description: "A Canadian-based worker-owned cooperative", 37 }, 38 { 39 id: "topphie", ··· 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 62 export 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"; ··· 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 }; ··· 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>
+74
web/src/css/modals.css
··· 10 animation: fadeIn 0.15s ease-out; 11 } 12 13 .modal-container { 14 background: var(--bg-secondary); 15 border-radius: var(--radius-lg); ··· 74 from { 75 opacity: 0; 76 } 77 to { 78 opacity: 1; 79 } ··· 84 opacity: 0; 85 transform: scale(0.96) translateY(-8px); 86 } 87 to { 88 opacity: 1; 89 transform: scale(1) translateY(0); ··· 379 380 .provider-arrow { 381 color: var(--text-tertiary); 382 } 383 384 .signup-form {
··· 10 animation: fadeIn 0.15s ease-out; 11 } 12 13 + .spinner { 14 + animation: spin 1s linear infinite; 15 + } 16 + 17 + @keyframes spin { 18 + from { 19 + transform: rotate(0deg); 20 + } 21 + 22 + to { 23 + transform: rotate(360deg); 24 + } 25 + } 26 + 27 .modal-container { 28 background: var(--bg-secondary); 29 border-radius: var(--radius-lg); ··· 88 from { 89 opacity: 0; 90 } 91 + 92 to { 93 opacity: 1; 94 } ··· 99 opacity: 0; 100 transform: scale(0.96) translateY(-8px); 101 } 102 + 103 to { 104 opacity: 1; 105 transform: scale(1) translateY(0); ··· 395 396 .provider-arrow { 397 color: var(--text-tertiary); 398 + } 399 + 400 + .signup-recommended { 401 + position: relative; 402 + margin-bottom: var(--spacing-md); 403 + } 404 + 405 + .signup-recommended-badge { 406 + position: absolute; 407 + top: -8px; 408 + left: 12px; 409 + background: var(--accent); 410 + color: white; 411 + font-size: 0.7rem; 412 + font-weight: 600; 413 + padding: 2px 8px; 414 + border-radius: var(--radius-sm); 415 + text-transform: uppercase; 416 + letter-spacing: 0.5px; 417 + z-index: 1; 418 + } 419 + 420 + .provider-card-featured { 421 + border-color: var(--accent); 422 + background: var(--accent-subtle); 423 + } 424 + 425 + .provider-card-featured:hover { 426 + border-color: var(--accent); 427 + background: var(--bg-tertiary); 428 + } 429 + 430 + .signup-toggle-others { 431 + display: flex; 432 + align-items: center; 433 + justify-content: center; 434 + gap: 6px; 435 + width: 100%; 436 + padding: 10px; 437 + background: transparent; 438 + border: none; 439 + color: var(--text-secondary); 440 + font-size: 0.85rem; 441 + cursor: pointer; 442 + transition: color 0.15s; 443 + } 444 + 445 + .signup-toggle-others:hover { 446 + color: var(--text-primary); 447 + } 448 + 449 + .toggle-chevron { 450 + transition: transform 0.2s ease; 451 + transform: rotate(90deg); 452 + } 453 + 454 + .toggle-chevron.open { 455 + transform: rotate(-90deg); 456 } 457 458 .signup-form {
+55 -52
web/src/pages/Feed.jsx
··· 1 - import { useState, useEffect, useMemo } from "react"; 2 import { useSearchParams } from "react-router-dom"; 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 import BookmarkCard from "../components/BookmarkCard"; ··· 45 46 const { user } = useAuth(); 47 48 - const fetchFeed = async (isLoadMore = false) => { 49 - try { 50 - if (isLoadMore) { 51 - setLoadingMore(true); 52 - } else { 53 - setLoading(true); 54 - } 55 56 - let creatorDid = ""; 57 58 - if (feedType === "my-feed") { 59 - if (user?.did) { 60 - creatorDid = user.did; 61 - } else { 62 - setAnnotations([]); 63 - setLoading(false); 64 - setLoadingMore(false); 65 - return; 66 } 67 - } 68 69 - const motivationMap = { 70 - commenting: "commenting", 71 - highlighting: "highlighting", 72 - bookmarking: "bookmarking", 73 - }; 74 - const motivation = motivationMap[filter] || ""; 75 - const limit = 50; 76 - const offset = isLoadMore ? annotations.length : 0; 77 78 - const data = await getAnnotationFeed( 79 - limit, 80 - offset, 81 - tagFilter || "", 82 - creatorDid, 83 - feedType, 84 - motivation, 85 - ); 86 87 - const newItems = data.items || []; 88 - if (newItems.length < limit) { 89 - setHasMore(false); 90 - } else { 91 - setHasMore(true); 92 - } 93 94 - if (isLoadMore) { 95 - setAnnotations((prev) => [...prev, ...newItems]); 96 - } else { 97 - setAnnotations(newItems); 98 } 99 - } catch (err) { 100 - setError(err.message); 101 - } finally { 102 - setLoading(false); 103 - setLoadingMore(false); 104 - } 105 - }; 106 107 useEffect(() => { 108 fetchFeed(false); 109 - }, [tagFilter, feedType, filter, user]); 110 111 const deduplicatedAnnotations = useMemo(() => { 112 const inCollectionUris = new Set();
··· 1 + import { useState, useEffect, useMemo, useCallback } from "react"; 2 import { useSearchParams } from "react-router-dom"; 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 import BookmarkCard from "../components/BookmarkCard"; ··· 45 46 const { user } = useAuth(); 47 48 + const fetchFeed = useCallback( 49 + async (isLoadMore = false) => { 50 + try { 51 + if (isLoadMore) { 52 + setLoadingMore(true); 53 + } else { 54 + setLoading(true); 55 + } 56 57 + let creatorDid = ""; 58 59 + if (feedType === "my-feed") { 60 + if (user?.did) { 61 + creatorDid = user.did; 62 + } else { 63 + setAnnotations([]); 64 + setLoading(false); 65 + setLoadingMore(false); 66 + return; 67 + } 68 } 69 70 + const motivationMap = { 71 + commenting: "commenting", 72 + highlighting: "highlighting", 73 + bookmarking: "bookmarking", 74 + }; 75 + const motivation = motivationMap[filter] || ""; 76 + const limit = 50; 77 + const offset = isLoadMore ? annotations.length : 0; 78 79 + const data = await getAnnotationFeed( 80 + limit, 81 + offset, 82 + tagFilter || "", 83 + creatorDid, 84 + feedType, 85 + motivation, 86 + ); 87 88 + const newItems = data.items || []; 89 + if (newItems.length < limit) { 90 + setHasMore(false); 91 + } else { 92 + setHasMore(true); 93 + } 94 95 + if (isLoadMore) { 96 + setAnnotations((prev) => [...prev, ...newItems]); 97 + } else { 98 + setAnnotations(newItems); 99 + } 100 + } catch (err) { 101 + setError(err.message); 102 + } finally { 103 + setLoading(false); 104 + setLoadingMore(false); 105 } 106 + }, 107 + [tagFilter, feedType, filter, user, annotations.length], 108 + ); 109 110 useEffect(() => { 111 fetchFeed(false); 112 + }, [fetchFeed]); 113 114 const deduplicatedAnnotations = useMemo(() => { 115 const inCollectionUris = new Set();