tangled
alpha
login
or
join now
margin.at
/
margin
87
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
87
fork
atom
overview
issues
4
pulls
1
pipelines
better signup flow and margin pds
scanash.com
1 month ago
75b4aa8e
8f210ad0
+356
-309
7 changed files
expand all
collapse all
unified
split
backend
cmd
server
main.go
internal
oauth
client.go
handler.go
web
src
api
client.js
components
SignUpModal.jsx
css
modals.css
pages
Feed.jsx
+1
backend/cmd/server/main.go
···
94
95
r.Get("/auth/login", oauthHandler.HandleLogin)
96
r.Post("/auth/start", oauthHandler.HandleStart)
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 {
0
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
}
0
0
0
0
0
0
0
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";
0
5
6
-
const PROVIDERS = [
0
0
0
0
0
0
0
0
0
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);
0
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");
0
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>
0
209
{error && (
210
<div className="error-message">
211
-
<AlertCircle size={14} /> {error}
0
212
</div>
213
)}
0
214
<div className="modal-actions">
215
<button
216
type="button"
217
-
className="btn btn-ghost"
218
-
onClick={() => setStep(1)}
0
0
0
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>
0
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
-
)}
0
0
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>
0
0
0
0
370
371
-
<p className="legal-text">
372
-
By creating an account, you agree to {selectedProvider?.name}
373
-
'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>
0
0
0
0
0
0
0
0
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",
0
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("");
0
0
0
0
0
0
66
const [loading, setLoading] = useState(false);
67
const [error, setError] = useState(null);
0
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;
0
0
80
}
0
81
0
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
}
0
0
0
0
0
0
0
0
0
0
0
0
0
90
} catch (err) {
91
console.error(err);
92
+
setError("Could not connect to this provider. Please try again.");
0
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}`;
0
107
}
108
0
0
0
0
0
109
try {
110
+
const result = await startSignup(serviceUrl);
0
0
0
0
0
0
0
111
if (result.authorizationUrl) {
112
window.location.href = result.authorizationUrl;
0
0
0
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
134
</div>
135
+
) : showCustomInput ? (
0
0
136
<div className="signup-step">
137
<h2>Custom Provider</h2>
138
+
<form onSubmit={handleCustomSubmit}>
0
0
0
0
0
139
<div className="form-group">
140
<label>PDS address (e.g. pds.example.com)</label>
141
<input
142
type="text"
0
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
+
) : (
0
0
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
186
187
+
{error && (
188
+
<div className="error-message" style={{ marginBottom: "1rem" }}>
189
+
<AlertCircle size={16} />
190
+
{error}
0
0
0
0
0
0
0
0
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 }}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
205
/>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
206
</div>
207
+
<div className="provider-info">
208
+
<h3>{RECOMMENDED_PROVIDER.name}</h3>
209
+
<span>{RECOMMENDED_PROVIDER.description}</span>
0
0
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)}
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
13
.modal-container {
14
background: var(--bg-secondary);
15
border-radius: var(--radius-lg);
···
74
from {
75
opacity: 0;
76
}
0
77
to {
78
opacity: 1;
79
}
···
84
opacity: 0;
85
transform: scale(0.96) translateY(-8px);
86
}
0
87
to {
88
opacity: 1;
89
transform: scale(1) translateY(0);
···
379
380
.provider-arrow {
381
color: var(--text-tertiary);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
-
}
0
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;
0
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);
0
0
0
0
0
0
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
}
0
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
+
);
0
0
0
0
109
110
useEffect(() => {
111
fetchFeed(false);
112
+
}, [fetchFeed]);
113
114
const deduplicatedAnnotations = useMemo(() => {
115
const inCollectionUris = new Set();