(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

prettier urls and minimal fixes

scanash00 6ed68983 1247426a

+467 -185
+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 + r.Get("/{handle}/annotation/{rkey}", ogHandler.HandleAnnotationPage) 101 + r.Get("/{handle}/highlight/{rkey}", ogHandler.HandleAnnotationPage) 102 + r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage) 103 + 100 104 r.Get("/collection/{uri}", ogHandler.HandleCollectionPage) 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 + 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 + 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 - "items": collections, 221 - "totalItems": len(collections), 244 + "items": apiCollections, 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 - annotation, err := h.db.GetAnnotationByURI(uri) 192 - if err != nil { 193 - http.Error(w, "Annotation not found", http.StatusNotFound) 194 - return 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 + } 195 223 } 196 224 197 - enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}) 198 - if len(enriched) == 0 { 199 - http.Error(w, "Annotation not found", http.StatusNotFound) 200 - return 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 + } 201 230 } 202 231 203 - w.Header().Set("Content-Type", "application/json") 204 - response := map[string]interface{}{ 205 - "@context": "http://www.w3.org/ns/anno.jsonld", 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 + } 206 240 } 207 - annJSON, _ := json.Marshal(enriched[0]) 208 - json.Unmarshal(annJSON, &response) 241 + 242 + http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound) 209 243 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 - URI string `json:"uri"` 103 - Name string `json:"name"` 104 - Icon string `json:"icon,omitempty"` 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"` 105 109 } 106 110 107 111 type APICollectionItem struct { ··· 458 462 if coll.Icon != nil { 459 463 icon = *coll.Icon 460 464 } 465 + desc := "" 466 + if coll.Description != nil { 467 + desc = *coll.Description 468 + } 461 469 apiItem.Collection = &APICollection{ 462 - URI: coll.URI, 463 - Name: coll.Name, 464 - Icon: icon, 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, 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 - "regexp" 19 18 "strings" 20 19 21 20 "golang.org/x/image/font" ··· 165 164 return false 166 165 } 167 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 + 168 182 func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) { 169 183 path := r.URL.Path 184 + var did, rkey, collectionType string 170 185 171 - var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`) 172 - matches := annotationMatch.FindStringSubmatch(path) 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 + } 173 203 174 - if len(matches) != 3 { 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 == "" { 175 218 h.serveIndexHTML(w, r) 176 219 return 177 220 } 178 221 179 - did, _ := url.QueryUnescape(matches[1]) 180 - rkey := matches[2] 181 - 182 222 if !isCrawler(r.UserAgent()) { 183 223 h.serveIndexHTML(w, r) 184 224 return 185 225 } 186 226 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 - } 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 + } 193 244 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 245 + colURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 246 + if h.tryServeType(w, colURI, "at.margin.collection") { 247 + return 248 + } 199 249 } 200 250 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 - } 251 + h.serveIndexHTML(w, r) 252 + } 207 253 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 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 + } 213 276 } 214 - 215 - h.serveIndexHTML(w, r) 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 - prefix := "/collection/" 221 - if !strings.HasPrefix(path, prefix) { 222 - h.serveIndexHTML(w, r) 223 - return 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 + } 224 308 } 225 309 226 - uriParam := strings.TrimPrefix(path, prefix) 227 - if uriParam == "" { 310 + if did == "" && rkey == "" { 228 311 h.serveIndexHTML(w, r) 229 312 return 230 - } 313 + } else if did != "" && rkey != "" { 314 + uri := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 231 315 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 - } 316 + if !isCrawler(r.UserAgent()) { 317 + h.serveIndexHTML(w, r) 318 + return 319 + } 241 320 242 - collection, err := h.db.GetCollectionByURI(uri) 243 - if err == nil && collection != nil { 244 - h.serveCollectionOG(w, collection) 245 - return 321 + collection, err := h.db.GetCollectionByURI(uri) 322 + if err == nil && collection != nil { 323 + h.serveCollectionOG(w, collection) 324 + return 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 + <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 + 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 + 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 + 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 + 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 - <ShareMenu uri={data.uri} text={data.text} /> 477 + <ShareMenu 478 + uri={data.uri} 479 + text={data.title || data.url} 480 + handle={data.author?.handle} 481 + type="Annotation" 482 + /> 477 483 <button 478 484 className="annotation-action" 479 485 onClick={() => { ··· 736 742 > 737 743 <HighlightIcon size={14} /> Highlight 738 744 </span> 739 - <ShareMenu uri={data.uri} text={highlightedText} /> 745 + <ShareMenu 746 + uri={data.uri} 747 + text={data.title || data.description} 748 + handle={data.author?.handle} 749 + type="Highlight" 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 + 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 - const data = normalizeAnnotation(bookmark || annotation); 19 + const raw = bookmark || annotation; 20 + const data = 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 - <ShareMenu uri={data.uri} text={data.title || data.description} /> 226 + <ShareMenu 227 + uri={data.uri} 228 + text={data.title || data.description} 229 + handle={data.author?.handle} 230 + type="Bookmark" 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 - to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 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 - customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 73 + uri={collection.uri} 74 + handle={author.handle} 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 - to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent( 10 - collection.authorDid || collection.author?.did, 11 - )}`} 9 + to={ 10 + collection.creator?.handle 11 + ? `/${collection.creator.handle}/collection/${collection.uri.split("/").pop()}` 12 + : `/collection/${encodeURIComponent(collection.uri)}` 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 - export default function ShareMenu({ uri, text, customUrl }) { 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 109 const uriParts = uri.split("/"); 109 - const did = uriParts[2]; 110 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]; 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 - import { useParams, Link } from "react-router-dom"; 3 - import AnnotationCard from "../components/AnnotationCard"; 2 + import { useParams, Link, useLocation } from "react-router-dom"; 3 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 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 + resolveHandle, 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 - const { uri, did, rkey } = useParams(); 18 + const { uri, did, rkey, handle, type } = useParams(); 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 - const annotationUri = uri || `at://${did}/at.margin.annotation/${rkey}`; 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]); 27 70 28 71 const refreshReplies = async () => { 29 - const repliesData = await getReplies(annotationUri); 72 + if (!targetUri) return; 73 + const repliesData = await getReplies(targetUri); 30 74 setReplies(repliesData.items || []); 31 75 }; 32 76 33 77 useEffect(() => { 34 78 async function fetchData() { 79 + if (!targetUri) return; 80 + 35 81 try { 36 82 setLoading(true); 37 83 const [annData, repliesData] = await Promise.all([ 38 - getAnnotation(annotationUri), 39 - getReplies(annotationUri).catch(() => ({ items: [] })), 84 + getAnnotation(targetUri), 85 + getReplies(targetUri).catch(() => ({ items: [] })), 40 86 ]); 41 - setAnnotation(annData); 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 - }, [annotationUri]); 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 - : annotationUri; 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 - rootUri: annotationUri, 114 + rootUri: targetUri, 69 115 rootCid: annotation?.cid || "", 70 116 text: replyText, 71 117 }); ··· 130 176 </Link> 131 177 </div> 132 178 133 - <AnnotationCard annotation={annotation} /> 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 + )} 134 192 135 - {} 136 - <div className="replies-section"> 137 - <h3 className="replies-title"> 138 - <MessageSquare size={18} /> 139 - Replies ({replies.length}) 140 - </h3> 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> 141 199 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> 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"> 151 230 <button 152 - onClick={() => setReplyingTo(null)} 153 - className="cancel-reply" 231 + className="btn btn-primary" 232 + disabled={posting || !replyText.trim()} 233 + onClick={() => handleReply()} 154 234 > 155 - × 235 + {posting ? "Posting..." : "Reply"} 156 236 </button> 157 237 </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 238 </div> 180 - </div> 181 - )} 239 + )} 182 240 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> 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 + )} 192 251 </div> 193 252 ); 194 253 }
+52 -39
web/src/pages/CollectionDetail.jsx
··· 6 6 getCollectionItems, 7 7 removeItemFromCollection, 8 8 deleteCollection, 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 - const { rkey, "*": wildcardPath } = useParams(); 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 - const authorDid = searchParams.get("author") || user?.did; 31 + const paramAuthorDid = searchParams.get("author"); 31 32 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; 33 + const isOwner = 34 + user?.did && 35 + (collection?.creator?.did === user.did || paramAuthorDid === user.did); 44 36 45 37 const fetchContext = async () => { 46 - if (!collectionUri || !authorDid) { 47 - setError("Invalid collection URL"); 48 - setLoading(false); 49 - return; 50 - } 51 - 52 38 try { 53 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 + 54 76 const [cols, itemsData] = await Promise.all([ 55 - getCollections(authorDid), 56 - getCollectionItems(collectionUri), 77 + getCollections(targetDid), 78 + getCollectionItems(targetUri), 57 79 ]); 58 80 59 81 const found = 60 - cols.items?.find((c) => c.uri === collectionUri) || 82 + cols.items?.find((c) => c.uri === targetUri) || 61 83 cols.items?.find( 62 - (c) => 63 - collectionUri && c.uri.endsWith(collectionUri.split("/").pop()), 84 + (c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()), 64 85 ); 86 + 65 87 if (!found) { 66 - console.error( 67 - "Collection not found. Looking for:", 68 - collectionUri, 69 - "Available:", 70 - cols.items?.map((c) => c.uri), 71 - ); 72 88 setError("Collection not found"); 73 89 return; 74 90 } ··· 83 99 }; 84 100 85 101 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]); 102 + fetchContext(); 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 - customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(authorDid)}`} 185 + uri={collection.uri} 186 + handle={collection.creator?.handle} 187 + type="Collection" 175 188 text={`Check out this collection: ${collection.name}`} 176 189 /> 177 190 {isOwner && (