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