Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 738 lines 26 kB view raw
1import { useState, useEffect, useRef } from "react"; 2import { Link } from "react-router-dom"; 3import { useAuth } from "../context/AuthContext"; 4import { 5 MessageSquare, 6 Highlighter, 7 Users, 8 ArrowRight, 9 Github, 10 Database, 11 Shield, 12 Zap, 13} from "lucide-react"; 14import { SiFirefox, SiGooglechrome, SiBluesky } from "react-icons/si"; 15import { FaEdge } from "react-icons/fa"; 16import logo from "../assets/logo.svg"; 17 18const isFirefox = 19 typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 20const isEdge = 21 typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 22 23function getExtensionInfo() { 24 if (isFirefox) { 25 return { 26 url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 27 Icon: SiFirefox, 28 label: "Firefox", 29 }; 30 } 31 if (isEdge) { 32 return { 33 url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 34 Icon: FaEdge, 35 label: "Edge", 36 }; 37 } 38 return { 39 url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 40 Icon: SiGooglechrome, 41 label: "Chrome", 42 }; 43} 44 45import { getAnnotations, normalizeAnnotation } from "../api/client"; 46import { formatDistanceToNow } from "date-fns"; 47 48function DemoAnnotation() { 49 const [annotations, setAnnotations] = useState([]); 50 const [loading, setLoading] = useState(true); 51 const [hoverPos, setHoverPos] = useState(null); 52 const [hoverVisible, setHoverVisible] = useState(false); 53 const [hoverAuthors, setHoverAuthors] = useState([]); 54 55 const [showPopover, setShowPopover] = useState(false); 56 const [popoverPos, setPopoverPos] = useState(null); 57 const [popoverAnnotations, setPopoverAnnotations] = useState([]); 58 59 const highlightRef = useRef(null); 60 const articleRef = useRef(null); 61 62 useEffect(() => { 63 getAnnotations({ source: "https://en.wikipedia.org/wiki/AT_Protocol" }) 64 .then((res) => { 65 const rawItems = res.items || (Array.isArray(res) ? res : []); 66 const normalized = rawItems.map(normalizeAnnotation); 67 setAnnotations(normalized); 68 }) 69 .catch((err) => { 70 console.error("Failed to fetch demo annotations:", err); 71 }) 72 .finally(() => { 73 setLoading(false); 74 }); 75 }, []); 76 77 useEffect(() => { 78 if (!showPopover) return; 79 const handleClickOutside = () => setShowPopover(false); 80 document.addEventListener("click", handleClickOutside); 81 return () => document.removeEventListener("click", handleClickOutside); 82 }, [showPopover]); 83 84 const getMatches = () => { 85 return annotations.filter( 86 (a) => 87 (a.selector?.exact && 88 a.selector.exact.includes("A handle serves as")) || 89 (a.quote && a.quote.includes("A handle serves as")), 90 ); 91 }; 92 93 const handleMouseEnter = () => { 94 const matches = getMatches(); 95 const authorsMap = new Map(); 96 matches.forEach((a) => { 97 const author = a.author || a.creator || { handle: "unknown" }; 98 const id = author.did || author.handle; 99 if (!authorsMap.has(id)) authorsMap.set(id, author); 100 }); 101 const unique = Array.from(authorsMap.values()); 102 103 setHoverAuthors(unique); 104 105 if (highlightRef.current && articleRef.current) { 106 const spanRect = highlightRef.current.getBoundingClientRect(); 107 const articleRect = articleRef.current.getBoundingClientRect(); 108 109 const visibleCount = Math.min(unique.length, 3); 110 const hasOverflow = unique.length > 3; 111 const countForCalc = visibleCount + (hasOverflow ? 1 : 0); 112 const width = countForCalc > 0 ? countForCalc * 18 + 10 : 0; 113 114 const top = spanRect.top - articleRect.top + spanRect.height / 2 - 14; 115 const left = spanRect.left - articleRect.left - width; 116 117 setHoverPos({ top, left }); 118 setHoverVisible(true); 119 } 120 }; 121 122 const handleMouseLeave = () => { 123 setHoverVisible(false); 124 }; 125 126 const handleHighlightClick = (e) => { 127 e.stopPropagation(); 128 const matches = getMatches(); 129 setPopoverAnnotations(matches); 130 131 if (highlightRef.current && articleRef.current) { 132 const spanRect = highlightRef.current.getBoundingClientRect(); 133 const articleRect = articleRef.current.getBoundingClientRect(); 134 135 const top = spanRect.top - articleRect.top + spanRect.height + 10; 136 let left = spanRect.left - articleRect.left; 137 138 if (left + 300 > articleRect.width) { 139 left = articleRect.width - 300; 140 } 141 142 setPopoverPos({ top, left }); 143 setShowPopover(true); 144 } 145 }; 146 147 const maxShow = 3; 148 const displayHoverAuthors = hoverAuthors.slice(0, maxShow); 149 const hoverOverflow = hoverAuthors.length - maxShow; 150 151 return ( 152 <div className="demo-window"> 153 <div className="demo-browser-bar"> 154 <div className="demo-browser-dots"> 155 <span></span> 156 <span></span> 157 <span></span> 158 </div> 159 <div className="demo-browser-url"> 160 <span>en.wikipedia.org/wiki/AT_Protocol</span> 161 </div> 162 </div> 163 <div className="demo-content"> 164 <div 165 className="demo-article" 166 ref={articleRef} 167 style={{ position: "relative" }} 168 > 169 {hoverPos && hoverAuthors.length > 0 && ( 170 <div 171 className={`demo-hover-indicator ${hoverVisible ? "visible" : ""}`} 172 style={{ 173 top: hoverPos.top, 174 left: hoverPos.left, 175 cursor: "pointer", 176 }} 177 onClick={handleHighlightClick} 178 > 179 {displayHoverAuthors.map((author, i) => 180 author.avatar ? ( 181 <img 182 key={i} 183 src={author.avatar} 184 className="demo-hover-avatar" 185 alt={author.handle} 186 onError={(e) => { 187 e.target.style.display = "none"; 188 e.target.nextSibling.style.display = "flex"; 189 }} 190 /> 191 ) : ( 192 <div key={i} className="demo-hover-avatar-fallback"> 193 {author.handle?.[0]?.toUpperCase() || "U"} 194 </div> 195 ), 196 )} 197 {hoverOverflow > 0 && ( 198 <div 199 className="demo-hover-avatar-fallback" 200 style={{ 201 background: "var(--bg-elevated)", 202 color: "var(--text-secondary)", 203 fontSize: 10, 204 }} 205 > 206 +{hoverOverflow} 207 </div> 208 )} 209 </div> 210 )} 211 212 {showPopover && popoverPos && ( 213 <div 214 className="demo-popover" 215 style={{ 216 top: popoverPos.top, 217 left: popoverPos.left, 218 }} 219 onClick={(e) => e.stopPropagation()} 220 > 221 <div className="demo-popover-header"> 222 <span> 223 {popoverAnnotations.length}{" "} 224 {popoverAnnotations.length === 1 ? "Comment" : "Comments"} 225 </span> 226 <button 227 className="demo-popover-close" 228 onClick={() => setShowPopover(false)} 229 > 230 231 </button> 232 </div> 233 <div className="demo-popover-scroll-area"> 234 {popoverAnnotations.length === 0 ? ( 235 <div style={{ padding: 14, fontSize: 13, color: "#666" }}> 236 No comments 237 </div> 238 ) : ( 239 popoverAnnotations.map((ann, i) => ( 240 <div key={ann.uri || i} className="demo-comment-item"> 241 <div className="demo-comment-header"> 242 <img 243 src={ann.author?.avatar || logo} 244 className="demo-comment-avatar" 245 onError={(e) => (e.target.src = logo)} 246 alt="" 247 /> 248 <span className="demo-comment-handle"> 249 @{ann.author?.handle || "user"} 250 </span> 251 </div> 252 <div className="demo-comment-text"> 253 {ann.text || ann.body?.value} 254 </div> 255 <div className="demo-comment-actions"> 256 <button className="demo-comment-action-btn"> 257 Reply 258 </button> 259 <button className="demo-comment-action-btn"> 260 Share 261 </button> 262 </div> 263 </div> 264 )) 265 )} 266 </div> 267 </div> 268 )} 269 <p className="demo-text"> 270 The AT Protocol utilizes a dual identifier system: a mutable handle, 271 in the form of a domain name, and an immutable decentralized 272 identifier (DID). 273 </p> 274 <p className="demo-text"> 275 <span 276 className="demo-highlight" 277 ref={highlightRef} 278 onMouseEnter={handleMouseEnter} 279 onMouseLeave={handleMouseLeave} 280 onClick={handleHighlightClick} 281 style={{ cursor: "pointer" }} 282 > 283 A handle serves as a verifiable user identifier. 284 </span>{" "} 285 Verification is by either of two equivalent methods proving control 286 of the domain name: Either a DNS query of a resource record with the 287 same name as the handle, or a request for a text file from a Web 288 service with the same name. 289 </p> 290 <p className="demo-text"> 291 DIDs resolve to DID documents, which contain references to key user 292 metadata, such as the user&apos;s handle, public keys, and data 293 repository. While any DID method could, in theory, be used by the 294 protocol if its components provide support for the method, in 295 practice only two methods are supported (&apos;blessed&apos;) by the 296 protocol&apos;s reference implementations: did:plc and did:web. The 297 validity of these identifiers can be verified by a registry which 298 hosts the DID&apos;s associated document and a file that is hosted 299 at a well-known location on the connected domain name, respectively. 300 </p> 301 </div> 302 <div className="demo-sidebar"> 303 <div className="demo-sidebar-header"> 304 <div className="demo-logo-section"> 305 <span className="demo-logo-icon"> 306 <img src={logo} alt="" style={{ width: 16, height: 16 }} /> 307 </span> 308 <span className="demo-logo-text">Margin</span> 309 </div> 310 <div className="demo-user-section"> 311 <span className="demo-user-handle">@margin.at</span> 312 </div> 313 </div> 314 <div className="demo-page-info"> 315 <span>en.wikipedia.org</span> 316 </div> 317 <div className="demo-annotations-list"> 318 {loading ? ( 319 <div style={{ padding: 20, textAlign: "center", color: "#666" }}> 320 Loading... 321 </div> 322 ) : annotations.length > 0 ? ( 323 annotations.map((ann, i) => ( 324 <div 325 key={ann.uri || i} 326 className={`demo-annotation ${i > 0 ? "demo-annotation-secondary" : ""}`} 327 > 328 <div className="demo-annotation-header"> 329 <div 330 className="demo-avatar" 331 style={{ background: "transparent" }} 332 > 333 <img 334 src={ann.author?.avatar || logo} 335 alt={ann.author?.handle || "User"} 336 style={{ 337 width: "100%", 338 height: "100%", 339 borderRadius: "50%", 340 }} 341 onError={(e) => { 342 e.target.src = logo; 343 }} 344 /> 345 </div> 346 <div className="demo-meta"> 347 <span className="demo-author"> 348 @{ann.author?.handle || "margin.at"} 349 </span> 350 <span className="demo-time"> 351 {ann.createdAt 352 ? formatDistanceToNow(new Date(ann.createdAt), { 353 addSuffix: true, 354 }) 355 : "recently"} 356 </span> 357 </div> 358 </div> 359 {ann.selector?.exact && ( 360 <p className="demo-quote"> 361 &ldquo;{ann.selector.exact}&rdquo; 362 </p> 363 )} 364 <p className="demo-comment">{ann.text || ann.body?.value}</p> 365 <button className="demo-jump-btn">Jump to text </button> 366 </div> 367 )) 368 ) : ( 369 <div 370 style={{ 371 padding: 20, 372 textAlign: "center", 373 color: "var(--text-tertiary)", 374 }} 375 > 376 No annotations found. 377 </div> 378 )} 379 </div> 380 </div> 381 </div> 382 </div> 383 ); 384} 385 386export default function Landing() { 387 const { user } = useAuth(); 388 const ext = getExtensionInfo(); 389 390 return ( 391 <div className="landing-page"> 392 <nav className="landing-nav"> 393 <Link to="/" className="landing-logo"> 394 <img src={logo} alt="Margin" /> 395 <span>Margin</span> 396 </Link> 397 <div className="landing-nav-links"> 398 <a 399 href="https://github.com/margin-at/margin" 400 target="_blank" 401 rel="noreferrer" 402 > 403 GitHub 404 </a> 405 <a 406 href="https://tangled.org/margin.at/margin" 407 target="_blank" 408 rel="noreferrer" 409 > 410 Tangled 411 </a> 412 <a 413 href="https://bsky.app/profile/margin.at" 414 target="_blank" 415 rel="noreferrer" 416 > 417 Bluesky 418 </a> 419 {user ? ( 420 <Link to="/home" className="btn btn-primary"> 421 Open App 422 </Link> 423 ) : ( 424 <Link to="/login" className="btn btn-primary"> 425 Sign In 426 </Link> 427 )} 428 </div> 429 </nav> 430 431 <section className="landing-hero"> 432 <div className="landing-hero-content"> 433 <div className="landing-badge"> 434 <SiBluesky size={14} /> 435 Built on ATProto 436 </div> 437 <h1 className="landing-title"> 438 Write in the margins 439 <br /> 440 <span className="landing-title-accent">of the web.</span> 441 </h1> 442 <p className="landing-subtitle"> 443 Margin is a social layer for reading online. Highlight passages, 444 leave thoughts in the margins, and see what others are thinking 445 about the pages you read. 446 </p> 447 <div className="landing-cta"> 448 <a 449 href={ext.url} 450 target="_blank" 451 rel="noreferrer" 452 className="btn btn-primary btn-lg" 453 > 454 <ext.Icon size={18} /> 455 Install for {ext.label} 456 </a> 457 {user ? ( 458 <Link to="/home" className="btn btn-secondary btn-lg"> 459 Open App 460 <ArrowRight size={18} /> 461 </Link> 462 ) : ( 463 <Link to="/login" className="btn btn-secondary btn-lg"> 464 Sign In with ATProto 465 <ArrowRight size={18} /> 466 </Link> 467 )} 468 </div> 469 <p className="landing-browsers"> 470 Also available for{" "} 471 {isFirefox ? ( 472 <> 473 <a 474 href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 475 target="_blank" 476 rel="noreferrer" 477 > 478 Edge 479 </a>{" "} 480 and{" "} 481 <a 482 href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 483 target="_blank" 484 rel="noreferrer" 485 > 486 Chrome 487 </a> 488 </> 489 ) : isEdge ? ( 490 <> 491 <a 492 href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 493 target="_blank" 494 rel="noreferrer" 495 > 496 Firefox 497 </a>{" "} 498 and{" "} 499 <a 500 href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 501 target="_blank" 502 rel="noreferrer" 503 > 504 Chrome 505 </a> 506 </> 507 ) : ( 508 <> 509 <a 510 href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 511 target="_blank" 512 rel="noreferrer" 513 > 514 Firefox 515 </a>{" "} 516 and{" "} 517 <a 518 href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 519 target="_blank" 520 rel="noreferrer" 521 > 522 Edge 523 </a> 524 </> 525 )} 526 </p> 527 </div> 528 </section> 529 530 <section className="landing-demo"> 531 <DemoAnnotation /> 532 </section> 533 534 <section className="landing-section"> 535 <h2 className="landing-section-title">How it works</h2> 536 <div className="landing-steps"> 537 <div className="landing-step"> 538 <div className="landing-step-num">1</div> 539 <div className="landing-step-content"> 540 <h3>Install & Login</h3> 541 <p> 542 Add Margin to your browser and sign in with your AT Protocol 543 handle. No new account needed, just your existing handle. 544 </p> 545 </div> 546 </div> 547 <div className="landing-step"> 548 <div className="landing-step-num">2</div> 549 <div className="landing-step-content"> 550 <h3>Annotate the Web</h3> 551 <p> 552 Highlight text on any page. Leave notes in the margins, ask 553 questions, or add context to the conversation precisely where it 554 belongs. 555 </p> 556 </div> 557 </div> 558 <div className="landing-step"> 559 <div className="landing-step-num">3</div> 560 <div className="landing-step-content"> 561 <h3>Share & Discover</h3> 562 <p> 563 Your annotations are published to your PDS. Discover what the 564 community is reading and discussing across the web. 565 </p> 566 </div> 567 </div> 568 </div> 569 </section> 570 571 <section className="landing-section landing-section-alt"> 572 <div className="landing-features-grid"> 573 <div className="landing-feature"> 574 <div className="landing-feature-icon"> 575 <Highlighter size={20} /> 576 </div> 577 <h3>Universal Highlights</h3> 578 <p> 579 Save passages from any article, paper, or post. Your collection 580 travels with you, independent of any single platform. 581 </p> 582 </div> 583 <div className="landing-feature"> 584 <div className="landing-feature-icon"> 585 <MessageSquare size={20} /> 586 </div> 587 <h3>Universal Notes</h3> 588 <p> 589 Move the discussion out of the comments section. Contextual 590 conversations that live right alongside the content. 591 </p> 592 </div> 593 <div className="landing-feature"> 594 <div className="landing-feature-icon"> 595 <Shield size={20} /> 596 </div> 597 <h3>Open Identity</h3> 598 <p> 599 Your data, your handle, your graph. Built on the AT Protocol for 600 true ownership and portability. 601 </p> 602 </div> 603 <div className="landing-feature"> 604 <div className="landing-feature-icon"> 605 <Users size={20} /> 606 </div> 607 <h3>Community Context</h3> 608 <p> 609 See the web with fresh eyes. Discover highlights and notes from 610 other readers directly on the page. 611 </p> 612 </div> 613 </div> 614 </section> 615 616 <section className="landing-section landing-protocol"> 617 <div className="landing-protocol-grid"> 618 <div className="landing-protocol-main"> 619 <h2>Your data, your identity</h2> 620 <p> 621 Margin is built on the{" "} 622 <a href="https://atproto.com" target="_blank" rel="noreferrer"> 623 AT Protocol 624 </a> 625 , the same open protocol that powers Bluesky. Sign in with your 626 existing Bluesky account or create a new one in your preferred 627 PDS. 628 </p> 629 <p> 630 Your annotations are stored in your PDS. You can export them 631 anytime, use them with other apps, or self-host your own server. 632 No vendor lock-in. 633 </p> 634 </div> 635 <div className="landing-protocol-features"> 636 <div className="landing-protocol-item"> 637 <Database size={20} /> 638 <div> 639 <strong>Portable data</strong> 640 <span>Export or migrate anytime</span> 641 </div> 642 </div> 643 <div className="landing-protocol-item"> 644 <Shield size={20} /> 645 <div> 646 <strong>You own your identity</strong> 647 <span>Use your own domain as handle</span> 648 </div> 649 </div> 650 <div className="landing-protocol-item"> 651 <Zap size={20} /> 652 <div> 653 <strong>Interoperable</strong> 654 <span>Works with the ATProto ecosystem</span> 655 </div> 656 </div> 657 <div className="landing-protocol-item"> 658 <Github size={20} /> 659 <div> 660 <strong>Open source</strong> 661 <span>Audit, contribute, self-host</span> 662 </div> 663 </div> 664 </div> 665 </div> 666 </section> 667 668 <section className="landing-section landing-final-cta"> 669 <h2>Start annotating today</h2> 670 <p>Free and open source. Sign in with ATProto to get started.</p> 671 <div className="landing-cta"> 672 <a 673 href={ext.url} 674 target="_blank" 675 rel="noreferrer" 676 className="btn btn-primary btn-lg" 677 > 678 <ext.Icon size={18} /> 679 Get the Extension 680 </a> 681 </div> 682 </section> 683 684 <footer className="landing-footer"> 685 <div className="landing-footer-grid"> 686 <div className="landing-footer-brand"> 687 <Link to="/" className="landing-logo"> 688 <img src={logo} alt="Margin" /> 689 <span>Margin</span> 690 </Link> 691 <p>Write in the margins of the web.</p> 692 </div> 693 <div className="landing-footer-links"> 694 <div className="landing-footer-col"> 695 <h4>Product</h4> 696 <a href={ext.url} target="_blank" rel="noreferrer"> 697 Browser Extension 698 </a> 699 <Link to="/home">Web App</Link> 700 </div> 701 <div className="landing-footer-col"> 702 <h4>Community</h4> 703 <a 704 href="https://github.com/margin-at/margin" 705 target="_blank" 706 rel="noreferrer" 707 > 708 GitHub 709 </a> 710 <a 711 href="https://tangled.org/margin.at/margin" 712 target="_blank" 713 rel="noreferrer" 714 > 715 Tangled 716 </a> 717 <a 718 href="https://bsky.app/profile/margin.at" 719 target="_blank" 720 rel="noreferrer" 721 > 722 Bluesky 723 </a> 724 </div> 725 <div className="landing-footer-col"> 726 <h4>Legal</h4> 727 <Link to="/privacy">Privacy Policy</Link> 728 <Link to="/terms">Terms of Service</Link> 729 </div> 730 </div> 731 </div> 732 <div className="landing-footer-bottom"> 733 <p>© {new Date().getFullYear()} Margin. Open source under MIT.</p> 734 </div> 735 </footer> 736 </div> 737 ); 738}