Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

prettier urls and minimal fixes

+467 -185
+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) 100 r.Get("/collection/{uri}", ogHandler.HandleCollectionPage) 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 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 195 } 196 197 - enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}) 198 - if len(enriched) == 0 { 199 - http.Error(w, "Annotation not found", http.StatusNotFound) 200 - return 201 } 202 203 - w.Header().Set("Content-Type", "application/json") 204 - response := map[string]interface{}{ 205 - "@context": "http://www.w3.org/ns/anno.jsonld", 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 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"` 105 } 106 107 type APICollectionItem struct { ··· 458 if coll.Icon != nil { 459 icon = *coll.Icon 460 } 461 apiItem.Collection = &APICollection{ 462 - URI: coll.URI, 463 - Name: coll.Name, 464 - Icon: icon, 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 168 func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) { 169 path := r.URL.Path 170 171 - var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`) 172 - matches := annotationMatch.FindStringSubmatch(path) 173 174 - if len(matches) != 3 { 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 - } 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 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 224 } 225 226 - uriParam := strings.TrimPrefix(path, prefix) 227 - if uriParam == "" { 228 h.serveIndexHTML(w, r) 229 return 230 - } 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 246 } 247 248 h.serveIndexHTML(w, r)
··· 15 "net/http" 16 "net/url" 17 "os" 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 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 + } 249 } 250 251 + h.serveIndexHTML(w, r) 252 + } 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 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 == "" { 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 + } 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 />} /> 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 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, 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} /> 477 <button 478 className="annotation-action" 479 onClick={() => { ··· 736 > 737 <HighlightIcon size={14} /> Highlight 738 </span> 739 - <ShareMenu uri={data.uri} text={highlightedText} /> 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, 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); 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} /> 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)}`} 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 - )}`} 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 ""; 108 const uriParts = uri.split("/"); 109 - const did = uriParts[2]; 110 const rkey = uriParts[uriParts.length - 1]; 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("/"); 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"; 4 import ReplyList from "../components/ReplyList"; 5 import { 6 getAnnotation, 7 getReplies, 8 createReply, 9 deleteReply, 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(); 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}`; 27 28 const refreshReplies = async () => { 29 - const repliesData = await getReplies(annotationUri); 30 setReplies(repliesData.items || []); 31 }; 32 33 useEffect(() => { 34 async function fetchData() { 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} /> 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> 151 <button 152 - onClick={() => setReplyingTo(null)} 153 - className="cancel-reply" 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> 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> 238 </div> 239 + )} 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, 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); 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 ); 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)}`} 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); 36 37 const fetchContext = async () => { 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()), 85 ); 86 + 87 if (!found) { 88 setError("Collection not found"); 89 return; 90 } ··· 99 }; 100 101 useEffect(() => { 102 + fetchContext(); 103 + }, [rkey, wildcardPath, handle, paramAuthorDid, user?.did]); 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 && (