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
94
95
95
r.Get("/auth/login", oauthHandler.HandleLogin)
96
96
r.Post("/auth/start", oauthHandler.HandleStart)
97
97
+
r.Post("/auth/signup", oauthHandler.HandleSignup)
97
98
r.Get("/auth/callback", oauthHandler.HandleCallback)
98
99
r.Post("/auth/logout", oauthHandler.HandleLogout)
99
100
r.Get("/auth/session", oauthHandler.HandleSession)
+19
backend/internal/oauth/client.go
···
208
208
return &meta, nil
209
209
}
210
210
211
211
+
func (c *Client) GetAuthServerMetadataForSignup(ctx context.Context, url string) (*AuthServerMetadata, error) {
212
212
+
url = strings.TrimSuffix(url, "/")
213
213
+
214
214
+
metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", url)
215
215
+
metaResp, err := http.Get(metaURL)
216
216
+
if err == nil && metaResp.StatusCode == 200 {
217
217
+
defer metaResp.Body.Close()
218
218
+
var meta AuthServerMetadata
219
219
+
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err == nil && meta.Issuer != "" {
220
220
+
return &meta, nil
221
221
+
}
222
222
+
}
223
223
+
if metaResp != nil {
224
224
+
metaResp.Body.Close()
225
225
+
}
226
226
+
227
227
+
return c.GetAuthServerMetadata(ctx, url)
228
228
+
}
229
229
+
211
230
func (c *Client) GeneratePKCE() (verifier, challenge string) {
212
231
b := make([]byte, 32)
213
232
rand.Read(b)
+83
-2
backend/internal/oauth/handler.go
···
283
283
})
284
284
}
285
285
286
286
+
func (h *Handler) HandleSignup(w http.ResponseWriter, r *http.Request) {
287
287
+
if r.Method != "POST" {
288
288
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
289
289
+
return
290
290
+
}
291
291
+
292
292
+
var req struct {
293
293
+
PdsURL string `json:"pds_url"`
294
294
+
}
295
295
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
296
296
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
297
297
+
return
298
298
+
}
299
299
+
300
300
+
if req.PdsURL == "" {
301
301
+
http.Error(w, "PDS URL is required", http.StatusBadRequest)
302
302
+
return
303
303
+
}
304
304
+
305
305
+
client := h.getDynamicClient(r)
306
306
+
ctx := r.Context()
307
307
+
308
308
+
meta, err := client.GetAuthServerMetadataForSignup(ctx, req.PdsURL)
309
309
+
if err != nil {
310
310
+
log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err)
311
311
+
w.Header().Set("Content-Type", "application/json")
312
312
+
w.WriteHeader(http.StatusBadRequest)
313
313
+
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to connect to PDS"})
314
314
+
return
315
315
+
}
316
316
+
317
317
+
dpopKey, err := client.GenerateDPoPKey()
318
318
+
if err != nil {
319
319
+
w.Header().Set("Content-Type", "application/json")
320
320
+
w.WriteHeader(http.StatusInternalServerError)
321
321
+
json.NewEncoder(w).Encode(map[string]string{"error": "Internal error"})
322
322
+
return
323
323
+
}
324
324
+
325
325
+
pkceVerifier, pkceChallenge := client.GeneratePKCE()
326
326
+
scope := "atproto offline_access blob:* include:at.margin.authFull"
327
327
+
328
328
+
parResp, state, dpopNonce, err := client.SendPAR(meta, "", scope, dpopKey, pkceChallenge)
329
329
+
if err != nil {
330
330
+
log.Printf("PAR request failed for signup: %v", err)
331
331
+
w.Header().Set("Content-Type", "application/json")
332
332
+
w.WriteHeader(http.StatusInternalServerError)
333
333
+
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate signup"})
334
334
+
return
335
335
+
}
336
336
+
337
337
+
pending := &PendingAuth{
338
338
+
State: state,
339
339
+
DID: "",
340
340
+
Handle: "",
341
341
+
PDS: req.PdsURL,
342
342
+
AuthServer: meta.TokenEndpoint,
343
343
+
Issuer: meta.Issuer,
344
344
+
PKCEVerifier: pkceVerifier,
345
345
+
DPoPKey: dpopKey,
346
346
+
DPoPNonce: dpopNonce,
347
347
+
CreatedAt: time.Now(),
348
348
+
}
349
349
+
350
350
+
h.pendingMu.Lock()
351
351
+
h.pending[state] = pending
352
352
+
h.pendingMu.Unlock()
353
353
+
354
354
+
authURL, _ := url.Parse(meta.AuthorizationEndpoint)
355
355
+
q := authURL.Query()
356
356
+
q.Set("client_id", client.ClientID)
357
357
+
q.Set("request_uri", parResp.RequestURI)
358
358
+
authURL.RawQuery = q.Encode()
359
359
+
360
360
+
w.Header().Set("Content-Type", "application/json")
361
361
+
json.NewEncoder(w).Encode(map[string]string{
362
362
+
"authorizationUrl": authURL.String(),
363
363
+
})
364
364
+
}
365
365
+
286
366
func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
287
367
client := h.getDynamicClient(r)
288
368
···
318
398
}
319
399
320
400
ctx := r.Context()
321
321
-
meta, err := client.GetAuthServerMetadata(ctx, pending.PDS)
401
401
+
meta, err := client.GetAuthServerMetadataForSignup(ctx, pending.PDS)
322
402
if err != nil {
403
403
+
log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err)
323
404
http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError)
324
405
return
325
406
}
···
330
411
return
331
412
}
332
413
333
333
-
if tokenResp.Sub != pending.DID {
414
414
+
if pending.DID != "" && tokenResp.Sub != pending.DID {
334
415
log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub)
335
416
http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest)
336
417
return
+7
web/src/api/client.js
···
452
452
body: JSON.stringify({ handle, invite_code: inviteCode }),
453
453
});
454
454
}
455
455
+
456
456
+
export async function startSignup(pdsUrl) {
457
457
+
return request(`${AUTH_BASE}/signup`, {
458
458
+
method: "POST",
459
459
+
body: JSON.stringify({ pds_url: pdsUrl }),
460
460
+
});
461
461
+
}
455
462
export async function getTrendingTags(limit = 10) {
456
463
return request(`${API_BASE}/tags/trending?limit=${limit}`);
457
464
}
+117
-255
web/src/components/SignUpModal.jsx
···
1
1
import { useState, useEffect } from "react";
2
2
import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react";
3
3
import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons";
4
4
-
import { describeServer, createAccount, startLogin } from "../api/client";
4
4
+
import { startSignup } from "../api/client";
5
5
+
import logo from "../assets/logo.svg";
5
6
6
6
-
const PROVIDERS = [
7
7
+
const RECOMMENDED_PROVIDER = {
8
8
+
id: "margin",
9
9
+
name: "Margin",
10
10
+
service: "https://pds.margin.at",
11
11
+
Icon: null,
12
12
+
description: "Hosted by Margin, the easiest way to get started",
13
13
+
isMargin: true,
14
14
+
};
15
15
+
16
16
+
const OTHER_PROVIDERS = [
7
17
{
8
18
id: "bluesky",
9
19
name: "Bluesky",
···
24
34
service: "https://northsky.social",
25
35
Icon: NorthskyIcon,
26
36
description: "A Canadian-based worker-owned cooperative",
27
27
-
inviteUrl: "https://northskysocial.com/join",
28
37
},
29
38
{
30
39
id: "topphie",
···
41
50
description: "An independent, self-hosted PDS instance",
42
51
},
43
52
{
44
44
-
id: "selfhosted",
45
45
-
name: "Self-Hosted",
53
53
+
id: "custom",
54
54
+
name: "Custom",
46
55
service: "",
47
56
custom: true,
48
57
Icon: null,
49
49
-
description: "Connect to your own Personal Data Server",
58
58
+
description: "Connect to your own or another custom PDS",
50
59
},
51
60
];
52
61
53
62
export default function SignUpModal({ onClose }) {
54
54
-
const [step, setStep] = useState(1);
55
55
-
const [selectedProvider, setSelectedProvider] = useState(null);
63
63
+
const [showOtherProviders, setShowOtherProviders] = useState(false);
64
64
+
const [showCustomInput, setShowCustomInput] = useState(false);
56
65
const [customService, setCustomService] = useState("");
57
57
-
const [formData, setFormData] = useState({
58
58
-
handle: "",
59
59
-
email: "",
60
60
-
password: "",
61
61
-
inviteCode: "",
62
62
-
});
63
66
const [loading, setLoading] = useState(false);
64
67
const [error, setError] = useState(null);
65
65
-
const [serverInfo, setServerInfo] = useState(null);
66
68
67
69
useEffect(() => {
68
70
document.body.style.overflow = "hidden";
···
71
73
};
72
74
}, []);
73
75
74
74
-
const handleProviderSelect = (provider) => {
75
75
-
setSelectedProvider(provider);
76
76
-
if (!provider.custom) {
77
77
-
checkServer(provider.service);
78
78
-
} else {
79
79
-
setStep(1.5);
76
76
+
const handleProviderSelect = async (provider) => {
77
77
+
if (provider.custom) {
78
78
+
setShowCustomInput(true);
79
79
+
return;
80
80
}
81
81
-
};
82
81
83
83
-
const checkServer = async (url) => {
84
82
setLoading(true);
85
83
setError(null);
84
84
+
86
85
try {
87
87
-
let serviceUrl = url.trim();
88
88
-
if (!serviceUrl.startsWith("http")) {
89
89
-
serviceUrl = `https://${serviceUrl}`;
86
86
+
const result = await startSignup(provider.service);
87
87
+
if (result.authorizationUrl) {
88
88
+
window.location.href = result.authorizationUrl;
90
89
}
91
91
-
92
92
-
const info = await describeServer(serviceUrl);
93
93
-
setServerInfo({
94
94
-
...info,
95
95
-
service: serviceUrl,
96
96
-
inviteCodeRequired: info.inviteCodeRequired ?? true,
97
97
-
});
98
98
-
99
99
-
if (selectedProvider?.custom) {
100
100
-
setSelectedProvider({ ...selectedProvider, service: serviceUrl });
101
101
-
}
102
102
-
103
103
-
setStep(2);
104
90
} catch (err) {
105
91
console.error(err);
106
106
-
setError("Could not connect to this PDS. Please check the URL.");
107
107
-
} finally {
92
92
+
setError("Could not connect to this provider. Please try again.");
108
93
setLoading(false);
109
94
}
110
95
};
111
96
112
112
-
const handleCreateAccount = async (e) => {
97
97
+
const handleCustomSubmit = async (e) => {
113
98
e.preventDefault();
114
114
-
if (!serverInfo) return;
99
99
+
if (!customService.trim()) return;
115
100
116
101
setLoading(true);
117
102
setError(null);
118
103
119
119
-
let domain =
120
120
-
serverInfo.selectedDomain || serverInfo.availableUserDomains[0];
121
121
-
if (!domain.startsWith(".")) {
122
122
-
domain = "." + domain;
104
104
+
let serviceUrl = customService.trim();
105
105
+
if (!serviceUrl.startsWith("http")) {
106
106
+
serviceUrl = `https://${serviceUrl}`;
123
107
}
124
108
125
125
-
const cleanHandle = formData.handle.trim().replace(/^@/, "");
126
126
-
const fullHandle = cleanHandle.endsWith(domain)
127
127
-
? cleanHandle
128
128
-
: `${cleanHandle}${domain}`;
129
129
-
130
109
try {
131
131
-
await createAccount(serverInfo.service, {
132
132
-
handle: fullHandle,
133
133
-
email: formData.email,
134
134
-
password: formData.password,
135
135
-
inviteCode: formData.inviteCode,
136
136
-
});
137
137
-
138
138
-
const result = await startLogin(fullHandle);
110
110
+
const result = await startSignup(serviceUrl);
139
111
if (result.authorizationUrl) {
140
112
window.location.href = result.authorizationUrl;
141
141
-
} else {
142
142
-
onClose();
143
143
-
alert("Account created! Please sign in.");
144
113
}
145
114
} catch (err) {
146
146
-
setError(err.message || "Failed to create account");
115
115
+
console.error(err);
116
116
+
setError("Could not connect to this PDS. Please check the URL.");
147
117
setLoading(false);
148
118
}
149
119
};
···
155
125
<X size={20} />
156
126
</button>
157
127
158
158
-
{step === 1 && (
159
159
-
<div className="signup-step">
160
160
-
<h2>Choose a Provider</h2>
161
161
-
<p className="signup-subtitle">
162
162
-
Where would you like to host your account?
128
128
+
{loading ? (
129
129
+
<div className="signup-step" style={{ textAlign: "center" }}>
130
130
+
<Loader2 size={32} className="spinner" />
131
131
+
<p style={{ marginTop: "1rem", color: "var(--text-secondary)" }}>
132
132
+
Connecting to provider...
163
133
</p>
164
164
-
<div className="provider-grid">
165
165
-
{PROVIDERS.map((p) => (
166
166
-
<button
167
167
-
key={p.id}
168
168
-
className="provider-card"
169
169
-
onClick={() => handleProviderSelect(p)}
170
170
-
>
171
171
-
<div className={`provider-icon ${p.wide ? "wide" : ""}`}>
172
172
-
{p.Icon ? (
173
173
-
<p.Icon size={p.wide ? 32 : 32} />
174
174
-
) : (
175
175
-
<span className="provider-initial">{p.name[0]}</span>
176
176
-
)}
177
177
-
</div>
178
178
-
<div className="provider-info">
179
179
-
<h3>{p.name}</h3>
180
180
-
<span>{p.description}</span>
181
181
-
</div>
182
182
-
<ChevronRight size={16} className="provider-arrow" />
183
183
-
</button>
184
184
-
))}
185
185
-
</div>
186
134
</div>
187
187
-
)}
188
188
-
189
189
-
{step === 1.5 && (
135
135
+
) : showCustomInput ? (
190
136
<div className="signup-step">
191
137
<h2>Custom Provider</h2>
192
192
-
<form
193
193
-
onSubmit={(e) => {
194
194
-
e.preventDefault();
195
195
-
checkServer(customService);
196
196
-
}}
197
197
-
>
138
138
+
<form onSubmit={handleCustomSubmit}>
198
139
<div className="form-group">
199
140
<label>PDS address (e.g. pds.example.com)</label>
200
141
<input
201
142
type="text"
202
202
-
className="login-input"
203
143
value={customService}
204
144
onChange={(e) => setCustomService(e.target.value)}
205
205
-
placeholder="example.com"
145
145
+
placeholder="pds.example.com"
206
146
autoFocus
207
147
/>
208
148
</div>
149
149
+
209
150
{error && (
210
151
<div className="error-message">
211
211
-
<AlertCircle size={14} /> {error}
152
152
+
<AlertCircle size={16} />
153
153
+
{error}
212
154
</div>
213
155
)}
156
156
+
214
157
<div className="modal-actions">
215
158
<button
216
159
type="button"
217
217
-
className="btn btn-ghost"
218
218
-
onClick={() => setStep(1)}
160
160
+
className="btn-secondary"
161
161
+
onClick={() => {
162
162
+
setShowCustomInput(false);
163
163
+
setError(null);
164
164
+
}}
219
165
>
220
166
Back
221
167
</button>
222
168
<button
223
169
type="submit"
224
224
-
className="btn btn-primary"
225
225
-
disabled={!customService || loading}
170
170
+
className="btn-primary"
171
171
+
disabled={!customService.trim()}
226
172
>
227
227
-
{loading ? <Loader2 className="animate-spin" /> : "Next"}
173
173
+
Continue
228
174
</button>
229
175
</div>
230
176
</form>
231
177
</div>
232
232
-
)}
233
233
-
234
234
-
{step === 2 && serverInfo && (
178
178
+
) : (
235
179
<div className="signup-step">
236
236
-
<div className="step-header">
237
237
-
<button className="btn-back" onClick={() => setStep(1)}>
238
238
-
← Back
239
239
-
</button>
240
240
-
<h2>
241
241
-
Create Account on {selectedProvider?.name || "Custom PDS"}
242
242
-
</h2>
243
243
-
</div>
244
244
-
245
245
-
<form onSubmit={handleCreateAccount} className="signup-form">
246
246
-
{serverInfo.inviteCodeRequired && (
247
247
-
<div className="form-group">
248
248
-
<label>Invite Code *</label>
249
249
-
<input
250
250
-
type="text"
251
251
-
className="login-input"
252
252
-
value={formData.inviteCode}
253
253
-
onChange={(e) =>
254
254
-
setFormData({ ...formData, inviteCode: e.target.value })
255
255
-
}
256
256
-
placeholder="bsky-social-xxxxx"
257
257
-
required
258
258
-
/>
259
259
-
{selectedProvider?.inviteUrl && (
260
260
-
<p
261
261
-
className="legal-text"
262
262
-
style={{ textAlign: "left", marginTop: "4px" }}
263
263
-
>
264
264
-
Need an invite code?{" "}
265
265
-
<a
266
266
-
href={selectedProvider.inviteUrl}
267
267
-
target="_blank"
268
268
-
rel="noopener noreferrer"
269
269
-
style={{ color: "var(--accent)" }}
270
270
-
>
271
271
-
Get one here
272
272
-
</a>
273
273
-
</p>
274
274
-
)}
275
275
-
</div>
276
276
-
)}
180
180
+
<h2>Create your account</h2>
181
181
+
<p className="signup-subtitle">
182
182
+
Margin uses the AT Protocol — the same decentralized network that
183
183
+
powers Bluesky. Your account will be hosted on a server of your
184
184
+
choice.
185
185
+
</p>
277
186
278
278
-
<div className="form-group">
279
279
-
<label>Email Address</label>
280
280
-
<input
281
281
-
type="email"
282
282
-
className="login-input"
283
283
-
value={formData.email}
284
284
-
onChange={(e) =>
285
285
-
setFormData({ ...formData, email: e.target.value })
286
286
-
}
287
287
-
placeholder="you@example.com"
288
288
-
required
289
289
-
/>
187
187
+
{error && (
188
188
+
<div className="error-message" style={{ marginBottom: "1rem" }}>
189
189
+
<AlertCircle size={16} />
190
190
+
{error}
290
191
</div>
192
192
+
)}
291
193
292
292
-
<div className="form-group">
293
293
-
<label>Password</label>
294
294
-
<input
295
295
-
type="password"
296
296
-
className="login-input"
297
297
-
value={formData.password}
298
298
-
onChange={(e) =>
299
299
-
setFormData({ ...formData, password: e.target.value })
300
300
-
}
301
301
-
required
302
302
-
/>
303
303
-
</div>
304
304
-
305
305
-
<div className="form-group">
306
306
-
<label>Handle</label>
307
307
-
<div className="handle-input-group">
308
308
-
<input
309
309
-
type="text"
310
310
-
className="login-input"
311
311
-
value={formData.handle}
312
312
-
onChange={(e) =>
313
313
-
setFormData({ ...formData, handle: e.target.value })
314
314
-
}
315
315
-
placeholder="username"
316
316
-
required
317
317
-
style={{ flex: 1 }}
194
194
+
<div className="signup-recommended">
195
195
+
<div className="signup-recommended-badge">Recommended</div>
196
196
+
<button
197
197
+
className="provider-card provider-card-featured"
198
198
+
onClick={() => handleProviderSelect(RECOMMENDED_PROVIDER)}
199
199
+
>
200
200
+
<div className="provider-icon">
201
201
+
<img
202
202
+
src={logo}
203
203
+
alt="Margin"
204
204
+
style={{ width: 24, height: 24 }}
318
205
/>
319
319
-
{serverInfo.availableUserDomains &&
320
320
-
serverInfo.availableUserDomains.length > 1 ? (
321
321
-
<select
322
322
-
className="login-input"
323
323
-
style={{
324
324
-
width: "auto",
325
325
-
flex: "0 0 auto",
326
326
-
paddingRight: "24px",
327
327
-
}}
328
328
-
onChange={(e) => {
329
329
-
setServerInfo({
330
330
-
...serverInfo,
331
331
-
selectedDomain: e.target.value,
332
332
-
});
333
333
-
}}
334
334
-
value={
335
335
-
serverInfo.selectedDomain ||
336
336
-
serverInfo.availableUserDomains[0]
337
337
-
}
338
338
-
>
339
339
-
{serverInfo.availableUserDomains.map((d) => (
340
340
-
<option key={d} value={d}>
341
341
-
.{d.startsWith(".") ? d.substring(1) : d}
342
342
-
</option>
343
343
-
))}
344
344
-
</select>
345
345
-
) : (
346
346
-
<span className="handle-suffix">
347
347
-
{(() => {
348
348
-
const d =
349
349
-
serverInfo.availableUserDomains?.[0] || "bsky.social";
350
350
-
return d.startsWith(".") ? d : `.${d}`;
351
351
-
})()}
352
352
-
</span>
353
353
-
)}
354
206
</div>
355
355
-
</div>
356
356
-
357
357
-
{error && (
358
358
-
<div className="error-message">
359
359
-
<AlertCircle size={14} /> {error}
207
207
+
<div className="provider-info">
208
208
+
<h3>{RECOMMENDED_PROVIDER.name}</h3>
209
209
+
<span>{RECOMMENDED_PROVIDER.description}</span>
360
210
</div>
361
361
-
)}
211
211
+
<ChevronRight size={16} className="provider-arrow" />
212
212
+
</button>
213
213
+
</div>
362
214
363
363
-
<button
364
364
-
type="submit"
365
365
-
className="btn btn-primary full-width"
366
366
-
disabled={loading}
367
367
-
>
368
368
-
{loading ? "Creating Account..." : "Create Account"}
369
369
-
</button>
215
215
+
<button
216
216
+
type="button"
217
217
+
className="signup-toggle-others"
218
218
+
onClick={() => setShowOtherProviders(!showOtherProviders)}
219
219
+
>
220
220
+
{showOtherProviders ? "Hide other options" : "More options"}
221
221
+
<ChevronRight
222
222
+
size={14}
223
223
+
className={`toggle-chevron ${showOtherProviders ? "open" : ""}`}
224
224
+
/>
225
225
+
</button>
370
226
371
371
-
<p className="legal-text">
372
372
-
By creating an account, you agree to {selectedProvider?.name}
373
373
-
's{" "}
374
374
-
{serverInfo.links?.termsOfService ? (
375
375
-
<a
376
376
-
href={serverInfo.links.termsOfService}
377
377
-
target="_blank"
378
378
-
rel="noopener noreferrer"
379
379
-
style={{ color: "var(--accent)" }}
227
227
+
{showOtherProviders && (
228
228
+
<div className="provider-grid">
229
229
+
{OTHER_PROVIDERS.map((p) => (
230
230
+
<button
231
231
+
key={p.id}
232
232
+
className="provider-card"
233
233
+
onClick={() => handleProviderSelect(p)}
380
234
>
381
381
-
Terms of Service
382
382
-
</a>
383
383
-
) : (
384
384
-
"Terms of Service"
385
385
-
)}
386
386
-
.
387
387
-
</p>
388
388
-
</form>
235
235
+
<div className={`provider-icon ${p.wide ? "wide" : ""}`}>
236
236
+
{p.Icon ? (
237
237
+
<p.Icon size={32} />
238
238
+
) : (
239
239
+
<span className="provider-initial">{p.name[0]}</span>
240
240
+
)}
241
241
+
</div>
242
242
+
<div className="provider-info">
243
243
+
<h3>{p.name}</h3>
244
244
+
<span>{p.description}</span>
245
245
+
</div>
246
246
+
<ChevronRight size={16} className="provider-arrow" />
247
247
+
</button>
248
248
+
))}
249
249
+
</div>
250
250
+
)}
389
251
</div>
390
252
)}
391
253
</div>
+74
web/src/css/modals.css
···
10
10
animation: fadeIn 0.15s ease-out;
11
11
}
12
12
13
13
+
.spinner {
14
14
+
animation: spin 1s linear infinite;
15
15
+
}
16
16
+
17
17
+
@keyframes spin {
18
18
+
from {
19
19
+
transform: rotate(0deg);
20
20
+
}
21
21
+
22
22
+
to {
23
23
+
transform: rotate(360deg);
24
24
+
}
25
25
+
}
26
26
+
13
27
.modal-container {
14
28
background: var(--bg-secondary);
15
29
border-radius: var(--radius-lg);
···
74
88
from {
75
89
opacity: 0;
76
90
}
91
91
+
77
92
to {
78
93
opacity: 1;
79
94
}
···
84
99
opacity: 0;
85
100
transform: scale(0.96) translateY(-8px);
86
101
}
102
102
+
87
103
to {
88
104
opacity: 1;
89
105
transform: scale(1) translateY(0);
···
379
395
380
396
.provider-arrow {
381
397
color: var(--text-tertiary);
398
398
+
}
399
399
+
400
400
+
.signup-recommended {
401
401
+
position: relative;
402
402
+
margin-bottom: var(--spacing-md);
403
403
+
}
404
404
+
405
405
+
.signup-recommended-badge {
406
406
+
position: absolute;
407
407
+
top: -8px;
408
408
+
left: 12px;
409
409
+
background: var(--accent);
410
410
+
color: white;
411
411
+
font-size: 0.7rem;
412
412
+
font-weight: 600;
413
413
+
padding: 2px 8px;
414
414
+
border-radius: var(--radius-sm);
415
415
+
text-transform: uppercase;
416
416
+
letter-spacing: 0.5px;
417
417
+
z-index: 1;
418
418
+
}
419
419
+
420
420
+
.provider-card-featured {
421
421
+
border-color: var(--accent);
422
422
+
background: var(--accent-subtle);
423
423
+
}
424
424
+
425
425
+
.provider-card-featured:hover {
426
426
+
border-color: var(--accent);
427
427
+
background: var(--bg-tertiary);
428
428
+
}
429
429
+
430
430
+
.signup-toggle-others {
431
431
+
display: flex;
432
432
+
align-items: center;
433
433
+
justify-content: center;
434
434
+
gap: 6px;
435
435
+
width: 100%;
436
436
+
padding: 10px;
437
437
+
background: transparent;
438
438
+
border: none;
439
439
+
color: var(--text-secondary);
440
440
+
font-size: 0.85rem;
441
441
+
cursor: pointer;
442
442
+
transition: color 0.15s;
443
443
+
}
444
444
+
445
445
+
.signup-toggle-others:hover {
446
446
+
color: var(--text-primary);
447
447
+
}
448
448
+
449
449
+
.toggle-chevron {
450
450
+
transition: transform 0.2s ease;
451
451
+
transform: rotate(90deg);
452
452
+
}
453
453
+
454
454
+
.toggle-chevron.open {
455
455
+
transform: rotate(-90deg);
382
456
}
383
457
384
458
.signup-form {
+55
-52
web/src/pages/Feed.jsx
···
1
1
-
import { useState, useEffect, useMemo } from "react";
1
1
+
import { useState, useEffect, useMemo, useCallback } from "react";
2
2
import { useSearchParams } from "react-router-dom";
3
3
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4
4
import BookmarkCard from "../components/BookmarkCard";
···
45
45
46
46
const { user } = useAuth();
47
47
48
48
-
const fetchFeed = async (isLoadMore = false) => {
49
49
-
try {
50
50
-
if (isLoadMore) {
51
51
-
setLoadingMore(true);
52
52
-
} else {
53
53
-
setLoading(true);
54
54
-
}
48
48
+
const fetchFeed = useCallback(
49
49
+
async (isLoadMore = false) => {
50
50
+
try {
51
51
+
if (isLoadMore) {
52
52
+
setLoadingMore(true);
53
53
+
} else {
54
54
+
setLoading(true);
55
55
+
}
55
56
56
56
-
let creatorDid = "";
57
57
+
let creatorDid = "";
57
58
58
58
-
if (feedType === "my-feed") {
59
59
-
if (user?.did) {
60
60
-
creatorDid = user.did;
61
61
-
} else {
62
62
-
setAnnotations([]);
63
63
-
setLoading(false);
64
64
-
setLoadingMore(false);
65
65
-
return;
59
59
+
if (feedType === "my-feed") {
60
60
+
if (user?.did) {
61
61
+
creatorDid = user.did;
62
62
+
} else {
63
63
+
setAnnotations([]);
64
64
+
setLoading(false);
65
65
+
setLoadingMore(false);
66
66
+
return;
67
67
+
}
66
68
}
67
67
-
}
68
69
69
69
-
const motivationMap = {
70
70
-
commenting: "commenting",
71
71
-
highlighting: "highlighting",
72
72
-
bookmarking: "bookmarking",
73
73
-
};
74
74
-
const motivation = motivationMap[filter] || "";
75
75
-
const limit = 50;
76
76
-
const offset = isLoadMore ? annotations.length : 0;
70
70
+
const motivationMap = {
71
71
+
commenting: "commenting",
72
72
+
highlighting: "highlighting",
73
73
+
bookmarking: "bookmarking",
74
74
+
};
75
75
+
const motivation = motivationMap[filter] || "";
76
76
+
const limit = 50;
77
77
+
const offset = isLoadMore ? annotations.length : 0;
77
78
78
78
-
const data = await getAnnotationFeed(
79
79
-
limit,
80
80
-
offset,
81
81
-
tagFilter || "",
82
82
-
creatorDid,
83
83
-
feedType,
84
84
-
motivation,
85
85
-
);
79
79
+
const data = await getAnnotationFeed(
80
80
+
limit,
81
81
+
offset,
82
82
+
tagFilter || "",
83
83
+
creatorDid,
84
84
+
feedType,
85
85
+
motivation,
86
86
+
);
86
87
87
87
-
const newItems = data.items || [];
88
88
-
if (newItems.length < limit) {
89
89
-
setHasMore(false);
90
90
-
} else {
91
91
-
setHasMore(true);
92
92
-
}
88
88
+
const newItems = data.items || [];
89
89
+
if (newItems.length < limit) {
90
90
+
setHasMore(false);
91
91
+
} else {
92
92
+
setHasMore(true);
93
93
+
}
93
94
94
94
-
if (isLoadMore) {
95
95
-
setAnnotations((prev) => [...prev, ...newItems]);
96
96
-
} else {
97
97
-
setAnnotations(newItems);
95
95
+
if (isLoadMore) {
96
96
+
setAnnotations((prev) => [...prev, ...newItems]);
97
97
+
} else {
98
98
+
setAnnotations(newItems);
99
99
+
}
100
100
+
} catch (err) {
101
101
+
setError(err.message);
102
102
+
} finally {
103
103
+
setLoading(false);
104
104
+
setLoadingMore(false);
98
105
}
99
99
-
} catch (err) {
100
100
-
setError(err.message);
101
101
-
} finally {
102
102
-
setLoading(false);
103
103
-
setLoadingMore(false);
104
104
-
}
105
105
-
};
106
106
+
},
107
107
+
[tagFilter, feedType, filter, user, annotations.length],
108
108
+
);
106
109
107
110
useEffect(() => {
108
111
fetchFeed(false);
109
109
-
}, [tagFilter, feedType, filter, user]);
112
112
+
}, [fetchFeed]);
110
113
111
114
const deduplicatedAnnotations = useMemo(() => {
112
115
const inCollectionUris = new Set();