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

Fix deleting highlights on feed not working, and implement OG for highligts and collections

+272 -4
+257 -2
backend/internal/api/og.go
··· 144 return 145 } 146 147 h.serveIndexHTML(w, r) 148 } 149 ··· 232 w.Write([]byte(htmlContent)) 233 } 234 235 func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) { 236 title := "Annotation on Margin" 237 description := "" ··· 417 } 418 } 419 } else { 420 - http.Error(w, "Record not found", http.StatusNotFound) 421 - return 422 } 423 } 424
··· 144 return 145 } 146 147 + highlightURI := fmt.Sprintf("at://%s/at.margin.highlight/%s", did, rkey) 148 + highlight, err := h.db.GetHighlightByURI(highlightURI) 149 + if err == nil && highlight != nil { 150 + h.serveHighlightOG(w, highlight) 151 + return 152 + } 153 + 154 + collectionURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 155 + collection, err := h.db.GetCollectionByURI(collectionURI) 156 + if err == nil && collection != nil { 157 + h.serveCollectionOG(w, collection) 158 + return 159 + } 160 + 161 h.serveIndexHTML(w, r) 162 } 163 ··· 246 w.Write([]byte(htmlContent)) 247 } 248 249 + func (h *OGHandler) serveHighlightOG(w http.ResponseWriter, highlight *db.Highlight) { 250 + title := "Highlight on Margin" 251 + description := "" 252 + 253 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 254 + var selector struct { 255 + Exact string `json:"exact"` 256 + } 257 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 258 + description = fmt.Sprintf("\"%s\"", selector.Exact) 259 + if len(description) > 200 { 260 + description = description[:197] + "...\"" 261 + } 262 + } 263 + } 264 + 265 + if highlight.TargetTitle != nil && *highlight.TargetTitle != "" { 266 + title = fmt.Sprintf("Highlight on: %s", *highlight.TargetTitle) 267 + if len(title) > 60 { 268 + title = title[:57] + "..." 269 + } 270 + } 271 + 272 + sourceDomain := "" 273 + if highlight.TargetSource != "" { 274 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 275 + sourceDomain = parsed.Host 276 + } 277 + } 278 + 279 + authorHandle := highlight.AuthorDID 280 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 281 + if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" { 282 + authorHandle = "@" + profile.Handle 283 + } 284 + 285 + if description == "" { 286 + description = fmt.Sprintf("A highlight by %s", authorHandle) 287 + if sourceDomain != "" { 288 + description += fmt.Sprintf(" on %s", sourceDomain) 289 + } 290 + } 291 + 292 + pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(highlight.URI[5:])) 293 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(highlight.URI)) 294 + 295 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 296 + <html lang="en"> 297 + <head> 298 + <meta charset="UTF-8"> 299 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 300 + <title>%s - Margin</title> 301 + <meta name="description" content="%s"> 302 + 303 + <!-- Open Graph --> 304 + <meta property="og:type" content="article"> 305 + <meta property="og:title" content="%s"> 306 + <meta property="og:description" content="%s"> 307 + <meta property="og:url" content="%s"> 308 + <meta property="og:image" content="%s"> 309 + <meta property="og:image:width" content="1200"> 310 + <meta property="og:image:height" content="630"> 311 + <meta property="og:site_name" content="Margin"> 312 + 313 + <!-- Twitter Card --> 314 + <meta name="twitter:card" content="summary_large_image"> 315 + <meta name="twitter:title" content="%s"> 316 + <meta name="twitter:description" content="%s"> 317 + <meta name="twitter:image" content="%s"> 318 + 319 + <!-- Author --> 320 + <meta property="article:author" content="%s"> 321 + 322 + <meta http-equiv="refresh" content="0; url=%s"> 323 + </head> 324 + <body> 325 + <p>Redirecting to <a href="%s">%s</a>...</p> 326 + </body> 327 + </html>`, 328 + html.EscapeString(title), 329 + html.EscapeString(description), 330 + html.EscapeString(title), 331 + html.EscapeString(description), 332 + html.EscapeString(pageURL), 333 + html.EscapeString(ogImageURL), 334 + html.EscapeString(title), 335 + html.EscapeString(description), 336 + html.EscapeString(ogImageURL), 337 + html.EscapeString(authorHandle), 338 + html.EscapeString(pageURL), 339 + html.EscapeString(pageURL), 340 + html.EscapeString(title), 341 + ) 342 + 343 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 344 + w.Write([]byte(htmlContent)) 345 + } 346 + 347 + func (h *OGHandler) serveCollectionOG(w http.ResponseWriter, collection *db.Collection) { 348 + icon := "📁" 349 + if collection.Icon != nil && *collection.Icon != "" { 350 + icon = *collection.Icon 351 + } 352 + 353 + title := fmt.Sprintf("%s %s", icon, collection.Name) 354 + description := "" 355 + if collection.Description != nil && *collection.Description != "" { 356 + description = *collection.Description 357 + if len(description) > 200 { 358 + description = description[:197] + "..." 359 + } 360 + } 361 + 362 + authorHandle := collection.AuthorDID 363 + var avatarURL string 364 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 365 + if profile, ok := profiles[collection.AuthorDID]; ok { 366 + if profile.Handle != "" { 367 + authorHandle = "@" + profile.Handle 368 + } 369 + if profile.Avatar != "" { 370 + avatarURL = profile.Avatar 371 + } 372 + } 373 + 374 + if description == "" { 375 + description = fmt.Sprintf("A collection by %s", authorHandle) 376 + } else { 377 + description = fmt.Sprintf("By %s • %s", authorHandle, description) 378 + } 379 + 380 + pageURL := fmt.Sprintf("%s/collection/%s", h.baseURL, url.PathEscape(collection.URI)) 381 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(collection.URI)) 382 + 383 + _ = avatarURL 384 + 385 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 386 + <html lang="en"> 387 + <head> 388 + <meta charset="UTF-8"> 389 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 390 + <title>%s - Margin</title> 391 + <meta name="description" content="%s"> 392 + 393 + <!-- Open Graph --> 394 + <meta property="og:type" content="article"> 395 + <meta property="og:title" content="%s"> 396 + <meta property="og:description" content="%s"> 397 + <meta property="og:url" content="%s"> 398 + <meta property="og:image" content="%s"> 399 + <meta property="og:image:width" content="1200"> 400 + <meta property="og:image:height" content="630"> 401 + <meta property="og:site_name" content="Margin"> 402 + 403 + <!-- Twitter Card --> 404 + <meta name="twitter:card" content="summary_large_image"> 405 + <meta name="twitter:title" content="%s"> 406 + <meta name="twitter:description" content="%s"> 407 + <meta name="twitter:image" content="%s"> 408 + 409 + <!-- Author --> 410 + <meta property="article:author" content="%s"> 411 + 412 + <meta http-equiv="refresh" content="0; url=%s"> 413 + </head> 414 + <body> 415 + <p>Redirecting to <a href="%s">%s</a>...</p> 416 + </body> 417 + </html>`, 418 + html.EscapeString(title), 419 + html.EscapeString(description), 420 + html.EscapeString(title), 421 + html.EscapeString(description), 422 + html.EscapeString(pageURL), 423 + html.EscapeString(ogImageURL), 424 + html.EscapeString(title), 425 + html.EscapeString(description), 426 + html.EscapeString(ogImageURL), 427 + html.EscapeString(authorHandle), 428 + html.EscapeString(pageURL), 429 + html.EscapeString(pageURL), 430 + html.EscapeString(title), 431 + ) 432 + 433 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 434 + w.Write([]byte(htmlContent)) 435 + } 436 + 437 func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) { 438 title := "Annotation on Margin" 439 description := "" ··· 619 } 620 } 621 } else { 622 + highlight, err := h.db.GetHighlightByURI(uri) 623 + if err == nil && highlight != nil { 624 + authorHandle = highlight.AuthorDID 625 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 626 + if profile, ok := profiles[highlight.AuthorDID]; ok { 627 + if profile.Handle != "" { 628 + authorHandle = "@" + profile.Handle 629 + } 630 + if profile.Avatar != "" { 631 + avatarURL = profile.Avatar 632 + } 633 + } 634 + 635 + text = "Highlight" 636 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 637 + var selector struct { 638 + Exact string `json:"exact"` 639 + } 640 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 641 + quote = selector.Exact 642 + } 643 + } 644 + 645 + if highlight.TargetSource != "" { 646 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 647 + sourceDomain = parsed.Host 648 + } 649 + } 650 + } else { 651 + collection, err := h.db.GetCollectionByURI(uri) 652 + if err == nil && collection != nil { 653 + authorHandle = collection.AuthorDID 654 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 655 + if profile, ok := profiles[collection.AuthorDID]; ok { 656 + if profile.Handle != "" { 657 + authorHandle = "@" + profile.Handle 658 + } 659 + if profile.Avatar != "" { 660 + avatarURL = profile.Avatar 661 + } 662 + } 663 + 664 + icon := "📁" 665 + if collection.Icon != nil && *collection.Icon != "" { 666 + icon = *collection.Icon 667 + } 668 + text = fmt.Sprintf("%s %s", icon, collection.Name) 669 + if collection.Description != nil && *collection.Description != "" { 670 + quote = *collection.Description 671 + } 672 + } else { 673 + http.Error(w, "Record not found", http.StatusNotFound) 674 + return 675 + } 676 + } 677 } 678 } 679
+1
web/src/components/AnnotationCard.jsx
··· 736 > 737 <HighlightIcon size={14} /> Highlight 738 </span> 739 <button 740 className="annotation-action" 741 onClick={() => {
··· 736 > 737 <HighlightIcon size={14} /> Highlight 738 </span> 739 + <ShareMenu uri={data.uri} text={highlightedText} /> 740 <button 741 className="annotation-action" 742 onClick={() => {
+14 -2
web/src/pages/Feed.jsx
··· 2 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 import BookmarkCard from "../components/BookmarkCard"; 4 import CollectionItemCard from "../components/CollectionItemCard"; 5 - import { getAnnotationFeed } from "../api/client"; 6 import { AlertIcon, InboxIcon } from "../components/Icons"; 7 8 export default function Feed() { ··· 129 item.type === "Highlight" || 130 item.motivation === "highlighting" 131 ) { 132 - return <HighlightCard key={item.id} highlight={item} />; 133 } 134 if (item.type === "Bookmark" || item.motivation === "bookmarking") { 135 return <BookmarkCard key={item.id} bookmark={item} />;
··· 2 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 import BookmarkCard from "../components/BookmarkCard"; 4 import CollectionItemCard from "../components/CollectionItemCard"; 5 + import { getAnnotationFeed, deleteHighlight } from "../api/client"; 6 import { AlertIcon, InboxIcon } from "../components/Icons"; 7 8 export default function Feed() { ··· 129 item.type === "Highlight" || 130 item.motivation === "highlighting" 131 ) { 132 + return ( 133 + <HighlightCard 134 + key={item.id} 135 + highlight={item} 136 + onDelete={async (uri) => { 137 + const rkey = uri.split("/").pop(); 138 + await deleteHighlight(rkey); 139 + setAnnotations((prev) => 140 + prev.filter((a) => a.id !== item.id), 141 + ); 142 + }} 143 + /> 144 + ); 145 } 146 if (item.type === "Bookmark" || item.motivation === "bookmarking") { 147 return <BookmarkCard key={item.id} bookmark={item} />;