Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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'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 ('blessed') by the
296 protocol'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'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 “{ann.selector.exact}”
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}