tangled
alpha
login
or
join now
margin.at
/
margin
86
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
86
fork
atom
overview
issues
4
pulls
1
pipelines
prettier urls and minimal fixes
scanash.com
2 months ago
6ed68983
1247426a
+467
-185
14 changed files
expand all
collapse all
unified
split
backend
cmd
server
main.go
internal
api
collections.go
handler.go
hydration.go
og.go
web
src
App.jsx
api
client.js
components
AnnotationCard.jsx
BookmarkCard.jsx
CollectionItemCard.jsx
CollectionRow.jsx
ShareMenu.jsx
pages
AnnotationDetail.jsx
CollectionDetail.jsx
+5
backend/cmd/server/main.go
···
97
97
r.Get("/og-image", ogHandler.HandleOGImage)
98
98
r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage)
99
99
r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage)
100
100
+
r.Get("/{handle}/annotation/{rkey}", ogHandler.HandleAnnotationPage)
101
101
+
r.Get("/{handle}/highlight/{rkey}", ogHandler.HandleAnnotationPage)
102
102
+
r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage)
103
103
+
100
104
r.Get("/collection/{uri}", ogHandler.HandleCollectionPage)
105
105
+
r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage)
101
106
102
107
staticDir := getEnv("STATIC_DIR", "../web/dist")
103
108
serveStatic(r, staticDir)
+26
-2
backend/internal/api/collections.go
···
213
213
return
214
214
}
215
215
216
216
+
profiles := fetchProfilesForDIDs([]string{authorDID})
217
217
+
creator := profiles[authorDID]
218
218
+
219
219
+
apiCollections := make([]APICollection, len(collections))
220
220
+
for i, c := range collections {
221
221
+
icon := ""
222
222
+
if c.Icon != nil {
223
223
+
icon = *c.Icon
224
224
+
}
225
225
+
desc := ""
226
226
+
if c.Description != nil {
227
227
+
desc = *c.Description
228
228
+
}
229
229
+
apiCollections[i] = APICollection{
230
230
+
URI: c.URI,
231
231
+
Name: c.Name,
232
232
+
Description: desc,
233
233
+
Icon: icon,
234
234
+
Creator: creator,
235
235
+
CreatedAt: c.CreatedAt,
236
236
+
IndexedAt: c.IndexedAt,
237
237
+
}
238
238
+
}
239
239
+
216
240
w.Header().Set("Content-Type", "application/json")
217
241
json.NewEncoder(w).Encode(map[string]interface{}{
218
242
"@context": "http://www.w3.org/ns/anno.jsonld",
219
243
"type": "Collection",
220
220
-
"items": collections,
221
221
-
"totalItems": len(collections),
244
244
+
"items": apiCollections,
245
245
+
"totalItems": len(apiCollections),
222
246
})
223
247
}
224
248
+47
-14
backend/internal/api/handler.go
···
188
188
return
189
189
}
190
190
191
191
-
annotation, err := h.db.GetAnnotationByURI(uri)
192
192
-
if err != nil {
193
193
-
http.Error(w, "Annotation not found", http.StatusNotFound)
194
194
-
return
191
191
+
serveResponse := func(data interface{}, context string) {
192
192
+
w.Header().Set("Content-Type", "application/json")
193
193
+
response := map[string]interface{}{
194
194
+
"@context": context,
195
195
+
}
196
196
+
jsonData, _ := json.Marshal(data)
197
197
+
json.Unmarshal(jsonData, &response)
198
198
+
json.NewEncoder(w).Encode(response)
199
199
+
}
200
200
+
201
201
+
if annotation, err := h.db.GetAnnotationByURI(uri); err == nil {
202
202
+
if enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}); len(enriched) > 0 {
203
203
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
204
204
+
return
205
205
+
}
206
206
+
}
207
207
+
208
208
+
if highlight, err := h.db.GetHighlightByURI(uri); err == nil {
209
209
+
if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 {
210
210
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
211
211
+
return
212
212
+
}
213
213
+
}
214
214
+
215
215
+
if strings.Contains(uri, "at.margin.annotation") {
216
216
+
highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1)
217
217
+
if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil {
218
218
+
if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 {
219
219
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
220
220
+
return
221
221
+
}
222
222
+
}
195
223
}
196
224
197
197
-
enriched, _ := hydrateAnnotations([]db.Annotation{*annotation})
198
198
-
if len(enriched) == 0 {
199
199
-
http.Error(w, "Annotation not found", http.StatusNotFound)
200
200
-
return
225
225
+
if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil {
226
226
+
if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 {
227
227
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
228
228
+
return
229
229
+
}
201
230
}
202
231
203
203
-
w.Header().Set("Content-Type", "application/json")
204
204
-
response := map[string]interface{}{
205
205
-
"@context": "http://www.w3.org/ns/anno.jsonld",
232
232
+
if strings.Contains(uri, "at.margin.annotation") {
233
233
+
bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1)
234
234
+
if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil {
235
235
+
if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 {
236
236
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
237
237
+
return
238
238
+
}
239
239
+
}
206
240
}
207
207
-
annJSON, _ := json.Marshal(enriched[0])
208
208
-
json.Unmarshal(annJSON, &response)
241
241
+
242
242
+
http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound)
209
243
210
210
-
json.NewEncoder(w).Encode(response)
211
244
}
212
245
213
246
func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) {
+18
-6
backend/internal/api/hydration.go
···
99
99
}
100
100
101
101
type APICollection struct {
102
102
-
URI string `json:"uri"`
103
103
-
Name string `json:"name"`
104
104
-
Icon string `json:"icon,omitempty"`
102
102
+
URI string `json:"uri"`
103
103
+
Name string `json:"name"`
104
104
+
Description string `json:"description,omitempty"`
105
105
+
Icon string `json:"icon,omitempty"`
106
106
+
Creator Author `json:"creator"`
107
107
+
CreatedAt time.Time `json:"createdAt"`
108
108
+
IndexedAt time.Time `json:"indexedAt"`
105
109
}
106
110
107
111
type APICollectionItem struct {
···
458
462
if coll.Icon != nil {
459
463
icon = *coll.Icon
460
464
}
465
465
+
desc := ""
466
466
+
if coll.Description != nil {
467
467
+
desc = *coll.Description
468
468
+
}
461
469
apiItem.Collection = &APICollection{
462
462
-
URI: coll.URI,
463
463
-
Name: coll.Name,
464
464
-
Icon: icon,
470
470
+
URI: coll.URI,
471
471
+
Name: coll.Name,
472
472
+
Description: desc,
473
473
+
Icon: icon,
474
474
+
Creator: profiles[coll.AuthorDID],
475
475
+
CreatedAt: coll.CreatedAt,
476
476
+
IndexedAt: coll.IndexedAt,
465
477
}
466
478
}
467
479
+131
-51
backend/internal/api/og.go
···
15
15
"net/http"
16
16
"net/url"
17
17
"os"
18
18
-
"regexp"
19
18
"strings"
20
19
21
20
"golang.org/x/image/font"
···
165
164
return false
166
165
}
167
166
167
167
+
func (h *OGHandler) resolveHandle(handle string) (string, error) {
168
168
+
resp, err := http.Get(fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle)))
169
169
+
if err == nil && resp.StatusCode == http.StatusOK {
170
170
+
var result struct {
171
171
+
Did string `json:"did"`
172
172
+
}
173
173
+
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.Did != "" {
174
174
+
return result.Did, nil
175
175
+
}
176
176
+
}
177
177
+
defer resp.Body.Close()
178
178
+
179
179
+
return "", fmt.Errorf("failed to resolve handle")
180
180
+
}
181
181
+
168
182
func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) {
169
183
path := r.URL.Path
184
184
+
var did, rkey, collectionType string
170
185
171
171
-
var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`)
172
172
-
matches := annotationMatch.FindStringSubmatch(path)
186
186
+
parts := strings.Split(strings.Trim(path, "/"), "/")
187
187
+
if len(parts) >= 2 {
188
188
+
firstPart, _ := url.QueryUnescape(parts[0])
189
189
+
190
190
+
if firstPart == "at" || firstPart == "annotation" {
191
191
+
if len(parts) >= 3 {
192
192
+
did, _ = url.QueryUnescape(parts[1])
193
193
+
rkey = parts[2]
194
194
+
}
195
195
+
} else {
196
196
+
if len(parts) >= 3 {
197
197
+
var err error
198
198
+
did, err = h.resolveHandle(firstPart)
199
199
+
if err != nil {
200
200
+
h.serveIndexHTML(w, r)
201
201
+
return
202
202
+
}
173
203
174
174
-
if len(matches) != 3 {
204
204
+
switch parts[1] {
205
205
+
case "highlight":
206
206
+
collectionType = "at.margin.highlight"
207
207
+
case "bookmark":
208
208
+
collectionType = "at.margin.bookmark"
209
209
+
case "annotation":
210
210
+
collectionType = "at.margin.annotation"
211
211
+
}
212
212
+
rkey = parts[2]
213
213
+
}
214
214
+
}
215
215
+
}
216
216
+
217
217
+
if did == "" || rkey == "" {
175
218
h.serveIndexHTML(w, r)
176
219
return
177
220
}
178
221
179
179
-
did, _ := url.QueryUnescape(matches[1])
180
180
-
rkey := matches[2]
181
181
-
182
222
if !isCrawler(r.UserAgent()) {
183
223
h.serveIndexHTML(w, r)
184
224
return
185
225
}
186
226
187
187
-
uri := fmt.Sprintf("at://%s/at.margin.annotation/%s", did, rkey)
188
188
-
annotation, err := h.db.GetAnnotationByURI(uri)
189
189
-
if err == nil && annotation != nil {
190
190
-
h.serveAnnotationOG(w, annotation)
191
191
-
return
192
192
-
}
227
227
+
if collectionType != "" {
228
228
+
uri := fmt.Sprintf("at://%s/%s/%s", did, collectionType, rkey)
229
229
+
if h.tryServeType(w, uri, collectionType) {
230
230
+
return
231
231
+
}
232
232
+
} else {
233
233
+
types := []string{
234
234
+
"at.margin.annotation",
235
235
+
"at.margin.bookmark",
236
236
+
"at.margin.highlight",
237
237
+
}
238
238
+
for _, t := range types {
239
239
+
uri := fmt.Sprintf("at://%s/%s/%s", did, t, rkey)
240
240
+
if h.tryServeType(w, uri, t) {
241
241
+
return
242
242
+
}
243
243
+
}
193
244
194
194
-
bookmarkURI := fmt.Sprintf("at://%s/at.margin.bookmark/%s", did, rkey)
195
195
-
bookmark, err := h.db.GetBookmarkByURI(bookmarkURI)
196
196
-
if err == nil && bookmark != nil {
197
197
-
h.serveBookmarkOG(w, bookmark)
198
198
-
return
245
245
+
colURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
246
246
+
if h.tryServeType(w, colURI, "at.margin.collection") {
247
247
+
return
248
248
+
}
199
249
}
200
250
201
201
-
highlightURI := fmt.Sprintf("at://%s/at.margin.highlight/%s", did, rkey)
202
202
-
highlight, err := h.db.GetHighlightByURI(highlightURI)
203
203
-
if err == nil && highlight != nil {
204
204
-
h.serveHighlightOG(w, highlight)
205
205
-
return
206
206
-
}
251
251
+
h.serveIndexHTML(w, r)
252
252
+
}
207
253
208
208
-
collectionURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
209
209
-
collection, err := h.db.GetCollectionByURI(collectionURI)
210
210
-
if err == nil && collection != nil {
211
211
-
h.serveCollectionOG(w, collection)
212
212
-
return
254
254
+
func (h *OGHandler) tryServeType(w http.ResponseWriter, uri, colType string) bool {
255
255
+
switch colType {
256
256
+
case "at.margin.annotation":
257
257
+
if item, err := h.db.GetAnnotationByURI(uri); err == nil && item != nil {
258
258
+
h.serveAnnotationOG(w, item)
259
259
+
return true
260
260
+
}
261
261
+
case "at.margin.highlight":
262
262
+
if item, err := h.db.GetHighlightByURI(uri); err == nil && item != nil {
263
263
+
h.serveHighlightOG(w, item)
264
264
+
return true
265
265
+
}
266
266
+
case "at.margin.bookmark":
267
267
+
if item, err := h.db.GetBookmarkByURI(uri); err == nil && item != nil {
268
268
+
h.serveBookmarkOG(w, item)
269
269
+
return true
270
270
+
}
271
271
+
case "at.margin.collection":
272
272
+
if item, err := h.db.GetCollectionByURI(uri); err == nil && item != nil {
273
273
+
h.serveCollectionOG(w, item)
274
274
+
return true
275
275
+
}
213
276
}
214
214
-
215
215
-
h.serveIndexHTML(w, r)
277
277
+
return false
216
278
}
217
279
218
280
func (h *OGHandler) HandleCollectionPage(w http.ResponseWriter, r *http.Request) {
219
281
path := r.URL.Path
220
220
-
prefix := "/collection/"
221
221
-
if !strings.HasPrefix(path, prefix) {
222
222
-
h.serveIndexHTML(w, r)
223
223
-
return
282
282
+
var did, rkey string
283
283
+
284
284
+
if strings.Contains(path, "/collection/") {
285
285
+
parts := strings.Split(strings.Trim(path, "/"), "/")
286
286
+
if len(parts) == 3 && parts[1] == "collection" {
287
287
+
handle, _ := url.QueryUnescape(parts[0])
288
288
+
rkey = parts[2]
289
289
+
var err error
290
290
+
did, err = h.resolveHandle(handle)
291
291
+
if err != nil {
292
292
+
h.serveIndexHTML(w, r)
293
293
+
return
294
294
+
}
295
295
+
} else if strings.HasPrefix(path, "/collection/") {
296
296
+
uriParam := strings.TrimPrefix(path, "/collection/")
297
297
+
if uriParam != "" {
298
298
+
uri, err := url.QueryUnescape(uriParam)
299
299
+
if err == nil {
300
300
+
parts := strings.Split(uri, "/")
301
301
+
if len(parts) >= 3 && strings.HasPrefix(uri, "at://") {
302
302
+
did = parts[2]
303
303
+
rkey = parts[len(parts)-1]
304
304
+
}
305
305
+
}
306
306
+
}
307
307
+
}
224
308
}
225
309
226
226
-
uriParam := strings.TrimPrefix(path, prefix)
227
227
-
if uriParam == "" {
310
310
+
if did == "" && rkey == "" {
228
311
h.serveIndexHTML(w, r)
229
312
return
230
230
-
}
313
313
+
} else if did != "" && rkey != "" {
314
314
+
uri := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
231
315
232
232
-
uri, err := url.QueryUnescape(uriParam)
233
233
-
if err != nil {
234
234
-
uri = uriParam
235
235
-
}
236
236
-
237
237
-
if !isCrawler(r.UserAgent()) {
238
238
-
h.serveIndexHTML(w, r)
239
239
-
return
240
240
-
}
316
316
+
if !isCrawler(r.UserAgent()) {
317
317
+
h.serveIndexHTML(w, r)
318
318
+
return
319
319
+
}
241
320
242
242
-
collection, err := h.db.GetCollectionByURI(uri)
243
243
-
if err == nil && collection != nil {
244
244
-
h.serveCollectionOG(w, collection)
245
245
-
return
321
321
+
collection, err := h.db.GetCollectionByURI(uri)
322
322
+
if err == nil && collection != nil {
323
323
+
h.serveCollectionOG(w, collection)
324
324
+
return
325
325
+
}
246
326
}
247
327
248
328
h.serveIndexHTML(w, r)
+18
web/src/App.jsx
···
34
34
<Route path="/annotation/:uri" element={<AnnotationDetail />} />
35
35
<Route path="/collections" element={<Collections />} />
36
36
<Route path="/collections/:rkey" element={<CollectionDetail />} />
37
37
+
<Route
38
38
+
path="/:handle/collection/:rkey"
39
39
+
element={<CollectionDetail />}
40
40
+
/>
41
41
+
42
42
+
<Route
43
43
+
path="/:handle/annotation/:rkey"
44
44
+
element={<AnnotationDetail />}
45
45
+
/>
46
46
+
<Route
47
47
+
path="/:handle/highlight/:rkey"
48
48
+
element={<AnnotationDetail />}
49
49
+
/>
50
50
+
<Route
51
51
+
path="/:handle/bookmark/:rkey"
52
52
+
element={<AnnotationDetail />}
53
53
+
/>
54
54
+
37
55
<Route path="/collection/*" element={<CollectionDetail />} />
38
56
<Route path="/privacy" element={<Privacy />} />
39
57
</Routes>
+9
web/src/api/client.js
···
371
371
return res.json();
372
372
}
373
373
374
374
+
export async function resolveHandle(handle) {
375
375
+
const res = await fetch(
376
376
+
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
377
377
+
);
378
378
+
if (!res.ok) throw new Error("Failed to resolve handle");
379
379
+
const data = await res.json();
380
380
+
return data.did;
381
381
+
}
382
382
+
374
383
export async function startLogin(handle, inviteCode) {
375
384
return request(`${AUTH_BASE}/start`, {
376
385
method: "POST",
+13
-2
web/src/components/AnnotationCard.jsx
···
5
5
import {
6
6
normalizeAnnotation,
7
7
normalizeHighlight,
8
8
+
normalizeBookmark,
8
9
deleteAnnotation,
9
10
likeAnnotation,
10
11
unlikeAnnotation,
···
473
474
<MessageIcon size={16} />
474
475
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
475
476
</button>
476
476
-
<ShareMenu uri={data.uri} text={data.text} />
477
477
+
<ShareMenu
478
478
+
uri={data.uri}
479
479
+
text={data.title || data.url}
480
480
+
handle={data.author?.handle}
481
481
+
type="Annotation"
482
482
+
/>
477
483
<button
478
484
className="annotation-action"
479
485
onClick={() => {
···
736
742
>
737
743
<HighlightIcon size={14} /> Highlight
738
744
</span>
739
739
-
<ShareMenu uri={data.uri} text={highlightedText} />
745
745
+
<ShareMenu
746
746
+
uri={data.uri}
747
747
+
text={data.title || data.description}
748
748
+
handle={data.author?.handle}
749
749
+
type="Highlight"
750
750
+
/>
740
751
<button
741
752
className="annotation-action"
742
753
onClick={() => {
+10
-2
web/src/components/BookmarkCard.jsx
···
3
3
import { Link } from "react-router-dom";
4
4
import {
5
5
normalizeAnnotation,
6
6
+
normalizeBookmark,
6
7
likeAnnotation,
7
8
unlikeAnnotation,
8
9
getLikeCount,
···
15
16
16
17
export default function BookmarkCard({ bookmark, annotation, onDelete }) {
17
18
const { user, login } = useAuth();
18
18
-
const data = normalizeAnnotation(bookmark || annotation);
19
19
+
const raw = bookmark || annotation;
20
20
+
const data =
21
21
+
raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw);
19
22
20
23
const [likeCount, setLikeCount] = useState(0);
21
24
const [isLiked, setIsLiked] = useState(false);
···
220
223
<HeartIcon filled={isLiked} size={16} />
221
224
{likeCount > 0 && <span>{likeCount}</span>}
222
225
</button>
223
223
-
<ShareMenu uri={data.uri} text={data.title || data.description} />
226
226
+
<ShareMenu
227
227
+
uri={data.uri}
228
228
+
text={data.title || data.description}
229
229
+
handle={data.author?.handle}
230
230
+
type="Bookmark"
231
231
+
/>
224
232
<button
225
233
className="annotation-action"
226
234
onClick={() => {
+4
-2
web/src/components/CollectionItemCard.jsx
···
54
54
</span>{" "}
55
55
added to{" "}
56
56
<Link
57
57
-
to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`}
57
57
+
to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`}
58
58
style={{
59
59
display: "inline-flex",
60
60
alignItems: "center",
···
70
70
</span>
71
71
<div style={{ marginLeft: "auto" }}>
72
72
<ShareMenu
73
73
-
customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`}
73
73
+
uri={collection.uri}
74
74
+
handle={author.handle}
75
75
+
type="Collection"
74
76
text={`Check out this collection by ${author.displayName}: ${collection.name}`}
75
77
/>
76
78
</div>
+5
-3
web/src/components/CollectionRow.jsx
···
6
6
return (
7
7
<div className="collection-row">
8
8
<Link
9
9
-
to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(
10
10
-
collection.authorDid || collection.author?.did,
11
11
-
)}`}
9
9
+
to={
10
10
+
collection.creator?.handle
11
11
+
? `/${collection.creator.handle}/collection/${collection.uri.split("/").pop()}`
12
12
+
: `/collection/${encodeURIComponent(collection.uri)}`
13
13
+
}
12
14
className="collection-row-content"
13
15
>
14
16
<div className="collection-row-icon">
+8
-2
web/src/components/ShareMenu.jsx
···
97
97
{ name: "Deer", domain: "deer.social", Icon: DeerIcon },
98
98
];
99
99
100
100
-
export default function ShareMenu({ uri, text, customUrl }) {
100
100
+
export default function ShareMenu({ uri, text, customUrl, handle, type }) {
101
101
const [isOpen, setIsOpen] = useState(false);
102
102
const [copied, setCopied] = useState(false);
103
103
const menuRef = useRef(null);
···
105
105
const getShareUrl = () => {
106
106
if (customUrl) return customUrl;
107
107
if (!uri) return "";
108
108
+
108
109
const uriParts = uri.split("/");
109
109
-
const did = uriParts[2];
110
110
const rkey = uriParts[uriParts.length - 1];
111
111
+
112
112
+
if (handle && type) {
113
113
+
return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`;
114
114
+
}
115
115
+
116
116
+
const did = uriParts[2];
111
117
return `${window.location.origin}/at/${did}/${rkey}`;
112
118
};
113
119
+121
-62
web/src/pages/AnnotationDetail.jsx
···
1
1
import { useState, useEffect } from "react";
2
2
-
import { useParams, Link } from "react-router-dom";
3
3
-
import AnnotationCard from "../components/AnnotationCard";
2
2
+
import { useParams, Link, useLocation } from "react-router-dom";
3
3
+
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4
4
+
import BookmarkCard from "../components/BookmarkCard";
4
5
import ReplyList from "../components/ReplyList";
5
6
import {
6
7
getAnnotation,
7
8
getReplies,
8
9
createReply,
9
10
deleteReply,
11
11
+
resolveHandle,
12
12
+
normalizeAnnotation,
10
13
} from "../api/client";
11
14
import { useAuth } from "../context/AuthContext";
12
15
import { MessageSquare } from "lucide-react";
13
16
14
17
export default function AnnotationDetail() {
15
15
-
const { uri, did, rkey } = useParams();
18
18
+
const { uri, did, rkey, handle, type } = useParams();
19
19
+
const location = useLocation();
16
20
const { isAuthenticated, user } = useAuth();
17
21
const [annotation, setAnnotation] = useState(null);
18
22
const [replies, setReplies] = useState([]);
···
23
27
const [posting, setPosting] = useState(false);
24
28
const [replyingTo, setReplyingTo] = useState(null);
25
29
26
26
-
const annotationUri = uri || `at://${did}/at.margin.annotation/${rkey}`;
30
30
+
const [targetUri, setTargetUri] = useState(uri);
31
31
+
32
32
+
useEffect(() => {
33
33
+
async function resolve() {
34
34
+
if (uri) {
35
35
+
setTargetUri(uri);
36
36
+
return;
37
37
+
}
38
38
+
39
39
+
if (handle && rkey) {
40
40
+
let collection = "at.margin.annotation";
41
41
+
if (type === "highlight") collection = "at.margin.highlight";
42
42
+
if (type === "bookmark") collection = "at.margin.bookmark";
43
43
+
44
44
+
try {
45
45
+
const resolvedDid = await resolveHandle(handle);
46
46
+
if (resolvedDid) {
47
47
+
setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`);
48
48
+
}
49
49
+
} catch (e) {
50
50
+
console.error("Failed to resolve handle:", e);
51
51
+
}
52
52
+
} else if (did && rkey) {
53
53
+
setTargetUri(`at://${did}/at.margin.annotation/${rkey}`);
54
54
+
} else {
55
55
+
const pathParts = location.pathname.split("/");
56
56
+
const atIndex = pathParts.indexOf("at");
57
57
+
if (
58
58
+
atIndex !== -1 &&
59
59
+
pathParts[atIndex + 1] &&
60
60
+
pathParts[atIndex + 2]
61
61
+
) {
62
62
+
setTargetUri(
63
63
+
`at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`,
64
64
+
);
65
65
+
}
66
66
+
}
67
67
+
}
68
68
+
resolve();
69
69
+
}, [uri, did, rkey, handle, type, location.pathname]);
27
70
28
71
const refreshReplies = async () => {
29
29
-
const repliesData = await getReplies(annotationUri);
72
72
+
if (!targetUri) return;
73
73
+
const repliesData = await getReplies(targetUri);
30
74
setReplies(repliesData.items || []);
31
75
};
32
76
33
77
useEffect(() => {
34
78
async function fetchData() {
79
79
+
if (!targetUri) return;
80
80
+
35
81
try {
36
82
setLoading(true);
37
83
const [annData, repliesData] = await Promise.all([
38
38
-
getAnnotation(annotationUri),
39
39
-
getReplies(annotationUri).catch(() => ({ items: [] })),
84
84
+
getAnnotation(targetUri),
85
85
+
getReplies(targetUri).catch(() => ({ items: [] })),
40
86
]);
41
41
-
setAnnotation(annData);
87
87
+
setAnnotation(normalizeAnnotation(annData));
42
88
setReplies(repliesData.items || []);
43
89
} catch (err) {
44
90
setError(err.message);
···
47
93
}
48
94
}
49
95
fetchData();
50
50
-
}, [annotationUri]);
96
96
+
}, [targetUri]);
51
97
52
98
const handleReply = async (e) => {
53
99
if (e) e.preventDefault();
···
57
103
setPosting(true);
58
104
const parentUri = replyingTo
59
105
? replyingTo.id || replyingTo.uri
60
60
-
: annotationUri;
106
106
+
: targetUri;
61
107
const parentCid = replyingTo
62
108
? replyingTo.cid || ""
63
109
: annotation?.cid || "";
···
65
111
await createReply({
66
112
parentUri,
67
113
parentCid,
68
68
-
rootUri: annotationUri,
114
114
+
rootUri: targetUri,
69
115
rootCid: annotation?.cid || "",
70
116
text: replyText,
71
117
});
···
130
176
</Link>
131
177
</div>
132
178
133
133
-
<AnnotationCard annotation={annotation} />
179
179
+
{annotation.type === "Highlight" ? (
180
180
+
<HighlightCard
181
181
+
highlight={annotation}
182
182
+
onDelete={() => (window.location.href = "/")}
183
183
+
/>
184
184
+
) : annotation.type === "Bookmark" ? (
185
185
+
<BookmarkCard
186
186
+
bookmark={annotation}
187
187
+
onDelete={() => (window.location.href = "/")}
188
188
+
/>
189
189
+
) : (
190
190
+
<AnnotationCard annotation={annotation} />
191
191
+
)}
134
192
135
135
-
{}
136
136
-
<div className="replies-section">
137
137
-
<h3 className="replies-title">
138
138
-
<MessageSquare size={18} />
139
139
-
Replies ({replies.length})
140
140
-
</h3>
193
193
+
{annotation.type !== "Bookmark" && annotation.type !== "Highlight" && (
194
194
+
<div className="replies-section">
195
195
+
<h3 className="replies-title">
196
196
+
<MessageSquare size={18} />
197
197
+
Replies ({replies.length})
198
198
+
</h3>
141
199
142
142
-
{isAuthenticated && (
143
143
-
<div className="reply-form card">
144
144
-
{replyingTo && (
145
145
-
<div className="replying-to-banner">
146
146
-
<span>
147
147
-
Replying to @
148
148
-
{(replyingTo.creator || replyingTo.author)?.handle ||
149
149
-
"unknown"}
150
150
-
</span>
200
200
+
{isAuthenticated && (
201
201
+
<div className="reply-form card">
202
202
+
{replyingTo && (
203
203
+
<div className="replying-to-banner">
204
204
+
<span>
205
205
+
Replying to @
206
206
+
{(replyingTo.creator || replyingTo.author)?.handle ||
207
207
+
"unknown"}
208
208
+
</span>
209
209
+
<button
210
210
+
onClick={() => setReplyingTo(null)}
211
211
+
className="cancel-reply"
212
212
+
>
213
213
+
×
214
214
+
</button>
215
215
+
</div>
216
216
+
)}
217
217
+
<textarea
218
218
+
value={replyText}
219
219
+
onChange={(e) => setReplyText(e.target.value)}
220
220
+
placeholder={
221
221
+
replyingTo
222
222
+
? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...`
223
223
+
: "Write a reply..."
224
224
+
}
225
225
+
className="reply-input"
226
226
+
rows={3}
227
227
+
disabled={posting}
228
228
+
/>
229
229
+
<div className="reply-form-actions">
151
230
<button
152
152
-
onClick={() => setReplyingTo(null)}
153
153
-
className="cancel-reply"
231
231
+
className="btn btn-primary"
232
232
+
disabled={posting || !replyText.trim()}
233
233
+
onClick={() => handleReply()}
154
234
>
155
155
-
×
235
235
+
{posting ? "Posting..." : "Reply"}
156
236
</button>
157
237
</div>
158
158
-
)}
159
159
-
<textarea
160
160
-
value={replyText}
161
161
-
onChange={(e) => setReplyText(e.target.value)}
162
162
-
placeholder={
163
163
-
replyingTo
164
164
-
? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...`
165
165
-
: "Write a reply..."
166
166
-
}
167
167
-
className="reply-input"
168
168
-
rows={3}
169
169
-
disabled={posting}
170
170
-
/>
171
171
-
<div className="reply-form-actions">
172
172
-
<button
173
173
-
className="btn btn-primary"
174
174
-
disabled={posting || !replyText.trim()}
175
175
-
onClick={() => handleReply()}
176
176
-
>
177
177
-
{posting ? "Posting..." : "Reply"}
178
178
-
</button>
179
238
</div>
180
180
-
</div>
181
181
-
)}
239
239
+
)}
182
240
183
183
-
<ReplyList
184
184
-
replies={replies}
185
185
-
rootUri={annotationUri}
186
186
-
user={user}
187
187
-
onReply={(reply) => setReplyingTo(reply)}
188
188
-
onDelete={handleDeleteReply}
189
189
-
isInline={false}
190
190
-
/>
191
191
-
</div>
241
241
+
<ReplyList
242
242
+
replies={replies}
243
243
+
rootUri={targetUri}
244
244
+
user={user}
245
245
+
onReply={(reply) => setReplyingTo(reply)}
246
246
+
onDelete={handleDeleteReply}
247
247
+
isInline={false}
248
248
+
/>
249
249
+
</div>
250
250
+
)}
192
251
</div>
193
252
);
194
253
}
+52
-39
web/src/pages/CollectionDetail.jsx
···
6
6
getCollectionItems,
7
7
removeItemFromCollection,
8
8
deleteCollection,
9
9
+
resolveHandle,
9
10
} from "../api/client";
10
11
import { useAuth } from "../context/AuthContext";
11
12
import CollectionModal from "../components/CollectionModal";
···
15
16
import ShareMenu from "../components/ShareMenu";
16
17
17
18
export default function CollectionDetail() {
18
18
-
const { rkey, "*": wildcardPath } = useParams();
19
19
+
const { rkey, handle, "*": wildcardPath } = useParams();
19
20
const location = useLocation();
20
21
const navigate = useNavigate();
21
22
const { user } = useAuth();
···
27
28
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
28
29
29
30
const searchParams = new URLSearchParams(location.search);
30
30
-
const authorDid = searchParams.get("author") || user?.did;
31
31
+
const paramAuthorDid = searchParams.get("author");
31
32
32
32
-
const getCollectionUri = () => {
33
33
-
if (wildcardPath) {
34
34
-
return decodeURIComponent(wildcardPath);
35
35
-
}
36
36
-
if (rkey && authorDid) {
37
37
-
return `at://${authorDid}/at.margin.collection/${rkey}`;
38
38
-
}
39
39
-
return null;
40
40
-
};
41
41
-
42
42
-
const collectionUri = getCollectionUri();
43
43
-
const isOwner = user?.did && authorDid === user.did;
33
33
+
const isOwner =
34
34
+
user?.did &&
35
35
+
(collection?.creator?.did === user.did || paramAuthorDid === user.did);
44
36
45
37
const fetchContext = async () => {
46
46
-
if (!collectionUri || !authorDid) {
47
47
-
setError("Invalid collection URL");
48
48
-
setLoading(false);
49
49
-
return;
50
50
-
}
51
51
-
52
38
try {
53
39
setLoading(true);
40
40
+
41
41
+
let targetUri = null;
42
42
+
let targetDid = paramAuthorDid || user?.did;
43
43
+
44
44
+
if (handle && rkey) {
45
45
+
try {
46
46
+
targetDid = await resolveHandle(handle);
47
47
+
targetUri = `at://${targetDid}/at.margin.collection/${rkey}`;
48
48
+
} catch (e) {
49
49
+
console.error("Failed to resolve handle", e);
50
50
+
}
51
51
+
} else if (wildcardPath) {
52
52
+
targetUri = decodeURIComponent(wildcardPath);
53
53
+
} else if (rkey && targetDid) {
54
54
+
targetUri = `at://${targetDid}/at.margin.collection/${rkey}`;
55
55
+
}
56
56
+
57
57
+
if (!targetUri) {
58
58
+
if (!user && !handle && !paramAuthorDid) {
59
59
+
setError("Please log in to view your collections");
60
60
+
return;
61
61
+
}
62
62
+
setError("Invalid collection URL");
63
63
+
return;
64
64
+
}
65
65
+
66
66
+
if (!targetDid && targetUri.startsWith("at://")) {
67
67
+
const parts = targetUri.split("/");
68
68
+
if (parts.length > 2) targetDid = parts[2];
69
69
+
}
70
70
+
71
71
+
if (!targetDid) {
72
72
+
setError("Could not determine collection owner");
73
73
+
return;
74
74
+
}
75
75
+
54
76
const [cols, itemsData] = await Promise.all([
55
55
-
getCollections(authorDid),
56
56
-
getCollectionItems(collectionUri),
77
77
+
getCollections(targetDid),
78
78
+
getCollectionItems(targetUri),
57
79
]);
58
80
59
81
const found =
60
60
-
cols.items?.find((c) => c.uri === collectionUri) ||
82
82
+
cols.items?.find((c) => c.uri === targetUri) ||
61
83
cols.items?.find(
62
62
-
(c) =>
63
63
-
collectionUri && c.uri.endsWith(collectionUri.split("/").pop()),
84
84
+
(c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()),
64
85
);
86
86
+
65
87
if (!found) {
66
66
-
console.error(
67
67
-
"Collection not found. Looking for:",
68
68
-
collectionUri,
69
69
-
"Available:",
70
70
-
cols.items?.map((c) => c.uri),
71
71
-
);
72
88
setError("Collection not found");
73
89
return;
74
90
}
···
83
99
};
84
100
85
101
useEffect(() => {
86
86
-
if (collectionUri && authorDid) {
87
87
-
fetchContext();
88
88
-
} else if (!user && !searchParams.get("author")) {
89
89
-
setLoading(false);
90
90
-
setError("Please log in to view your collections");
91
91
-
}
92
92
-
}, [rkey, wildcardPath, authorDid, user]);
102
102
+
fetchContext();
103
103
+
}, [rkey, wildcardPath, handle, paramAuthorDid, user?.did]);
93
104
94
105
const handleEditSuccess = () => {
95
106
fetchContext();
···
171
182
</div>
172
183
<div className="collection-detail-actions">
173
184
<ShareMenu
174
174
-
customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(authorDid)}`}
185
185
+
uri={collection.uri}
186
186
+
handle={collection.creator?.handle}
187
187
+
type="Collection"
175
188
text={`Check out this collection: ${collection.name}`}
176
189
/>
177
190
{isOwner && (