Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at v0.1.13 26 kB view raw
1import { useState, useEffect } from "react"; 2import { useAuth } from "../context/AuthContext"; 3import ReplyList from "./ReplyList"; 4import { Link } from "react-router-dom"; 5import { 6 normalizeAnnotation, 7 normalizeHighlight, 8 normalizeBookmark, 9 deleteAnnotation, 10 likeAnnotation, 11 unlikeAnnotation, 12 getReplies, 13 createReply, 14 deleteReply, 15 getLikeCount, 16 updateAnnotation, 17 updateHighlight, 18 updateBookmark, 19 getEditHistory, 20} from "../api/client"; 21import { 22 HeartIcon, 23 MessageIcon, 24 TrashIcon, 25 ExternalLinkIcon, 26 HighlightIcon, 27 BookmarkIcon, 28} from "./Icons"; 29import { Folder, Edit2, Save, X, Clock } from "lucide-react"; 30import ShareMenu from "./ShareMenu"; 31 32function buildTextFragmentUrl(baseUrl, selector) { 33 if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { 34 return baseUrl; 35 } 36 37 let fragment = ":~:text="; 38 if (selector.prefix) { 39 fragment += encodeURIComponent(selector.prefix) + "-,"; 40 } 41 fragment += encodeURIComponent(selector.exact); 42 if (selector.suffix) { 43 fragment += ",-" + encodeURIComponent(selector.suffix); 44 } 45 46 return baseUrl + "#" + fragment; 47} 48 49const truncateUrl = (url, maxLength = 60) => { 50 if (!url) return ""; 51 try { 52 const parsed = new URL(url); 53 const fullPath = parsed.host + parsed.pathname; 54 if (fullPath.length > maxLength) 55 return fullPath.substring(0, maxLength) + "..."; 56 return fullPath; 57 } catch { 58 return url.length > maxLength ? url.substring(0, maxLength) + "..." : url; 59 } 60}; 61 62export default function AnnotationCard({ 63 annotation, 64 onDelete, 65 onAddToCollection, 66}) { 67 const { user, login } = useAuth(); 68 const data = normalizeAnnotation(annotation); 69 70 const [likeCount, setLikeCount] = useState(data.likeCount || 0); 71 const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); 72 const [deleting, setDeleting] = useState(false); 73 const [isEditing, setIsEditing] = useState(false); 74 const [editText, setEditText] = useState(data.text || ""); 75 const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 76 const [saving, setSaving] = useState(false); 77 78 const [showHistory, setShowHistory] = useState(false); 79 const [editHistory, setEditHistory] = useState([]); 80 const [loadingHistory, setLoadingHistory] = useState(false); 81 82 const [replies, setReplies] = useState([]); 83 const [replyCount, setReplyCount] = useState(data.replyCount || 0); 84 const [showReplies, setShowReplies] = useState(false); 85 const [replyingTo, setReplyingTo] = useState(null); 86 const [replyText, setReplyText] = useState(""); 87 const [posting, setPosting] = useState(false); 88 89 const isOwner = user?.did && data.author?.did === user.did; 90 91 const [hasEditHistory, setHasEditHistory] = useState(false); 92 93 useEffect(() => {}, []); 94 95 const fetchHistory = async () => { 96 if (showHistory) { 97 setShowHistory(false); 98 return; 99 } 100 try { 101 setLoadingHistory(true); 102 setShowHistory(true); 103 const history = await getEditHistory(data.uri); 104 setEditHistory(history); 105 } catch (err) { 106 console.error("Failed to fetch history:", err); 107 } finally { 108 setLoadingHistory(false); 109 } 110 }; 111 112 const handlePostReply = async (parentReply) => { 113 if (!replyText.trim()) return; 114 115 try { 116 setPosting(true); 117 const parentUri = parentReply 118 ? parentReply.id || parentReply.uri 119 : data.uri; 120 const parentCid = parentReply 121 ? parentReply.cid 122 : annotation.cid || data.cid; 123 124 await createReply({ 125 parentUri, 126 parentCid: parentCid || "", 127 rootUri: data.uri, 128 rootCid: annotation.cid || data.cid || "", 129 text: replyText, 130 }); 131 132 setReplyText(""); 133 setReplyingTo(null); 134 135 const res = await getReplies(data.uri); 136 if (res.items) { 137 setReplies(res.items); 138 setReplyCount(res.items.length); 139 } 140 } catch (err) { 141 alert("Failed to post reply: " + err.message); 142 } finally { 143 setPosting(false); 144 } 145 }; 146 147 const handleSaveEdit = async () => { 148 try { 149 setSaving(true); 150 const tagList = editTags 151 .split(",") 152 .map((t) => t.trim()) 153 .filter(Boolean); 154 await updateAnnotation(data.uri, editText, tagList); 155 setIsEditing(false); 156 if (annotation.body) annotation.body.value = editText; 157 else if (annotation.text) annotation.text = editText; 158 if (annotation.tags) annotation.tags = tagList; 159 data.tags = tagList; 160 } catch (err) { 161 alert("Failed to update: " + err.message); 162 } finally { 163 setSaving(false); 164 } 165 }; 166 167 const formatDate = (dateString, simple = true) => { 168 if (!dateString) return ""; 169 const date = new Date(dateString); 170 const now = new Date(); 171 const diff = now - date; 172 const minutes = Math.floor(diff / 60000); 173 const hours = Math.floor(diff / 3600000); 174 const days = Math.floor(diff / 86400000); 175 if (minutes < 1) return "just now"; 176 if (minutes < 60) return `${minutes}m`; 177 if (hours < 24) return `${hours}h`; 178 if (days < 7) return `${days}d`; 179 if (simple) 180 return date.toLocaleDateString("en-US", { 181 month: "short", 182 day: "numeric", 183 }); 184 return date.toLocaleString(); 185 }; 186 187 const authorDisplayName = data.author?.displayName || data.author?.handle; 188 const authorHandle = data.author?.handle; 189 const authorAvatar = data.author?.avatar; 190 const authorDid = data.author?.did; 191 const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null; 192 const highlightedText = 193 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 194 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 195 196 const handleLike = async () => { 197 if (!user) { 198 login(); 199 return; 200 } 201 try { 202 if (isLiked) { 203 setIsLiked(false); 204 setLikeCount((prev) => Math.max(0, prev - 1)); 205 await unlikeAnnotation(data.uri); 206 } else { 207 setIsLiked(true); 208 setLikeCount((prev) => prev + 1); 209 const cid = annotation.cid || data.cid || ""; 210 if (data.uri && cid) await likeAnnotation(data.uri, cid); 211 } 212 } catch (err) { 213 setIsLiked(!isLiked); 214 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 215 console.error("Failed to toggle like:", err); 216 } 217 }; 218 219 const handleShare = async () => { 220 const uriParts = data.uri.split("/"); 221 const did = uriParts[2]; 222 const rkey = uriParts[uriParts.length - 1]; 223 const shareUrl = `${window.location.origin}/at/${did}/${rkey}`; 224 225 if (navigator.share) { 226 try { 227 await navigator.share({ 228 title: "Margin Annotation", 229 text: data.text?.substring(0, 100), 230 url: shareUrl, 231 }); 232 } catch (err) {} 233 } else { 234 try { 235 await navigator.clipboard.writeText(shareUrl); 236 alert("Link copied!"); 237 } catch { 238 prompt("Copy this link:", shareUrl); 239 } 240 } 241 }; 242 243 const handleDelete = async () => { 244 if (!confirm("Delete this annotation? This cannot be undone.")) return; 245 try { 246 setDeleting(true); 247 const parts = data.uri.split("/"); 248 const rkey = parts[parts.length - 1]; 249 await deleteAnnotation(rkey); 250 if (onDelete) onDelete(data.uri); 251 else window.location.reload(); 252 } catch (err) { 253 alert("Failed to delete: " + err.message); 254 } finally { 255 setDeleting(false); 256 } 257 }; 258 259 return ( 260 <article className="card annotation-card"> 261 <header className="annotation-header"> 262 <div className="annotation-header-left"> 263 <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 264 <div className="annotation-avatar"> 265 {authorAvatar ? ( 266 <img src={authorAvatar} alt={authorDisplayName} /> 267 ) : ( 268 <span> 269 {(authorDisplayName || authorHandle || "??") 270 ?.substring(0, 2) 271 .toUpperCase()} 272 </span> 273 )} 274 </div> 275 </Link> 276 <div className="annotation-meta"> 277 <div className="annotation-author-row"> 278 <Link 279 to={marginProfileUrl || "#"} 280 className="annotation-author-link" 281 > 282 <span className="annotation-author">{authorDisplayName}</span> 283 </Link> 284 {authorHandle && ( 285 <a 286 href={`https://bsky.app/profile/${authorHandle}`} 287 target="_blank" 288 rel="noopener noreferrer" 289 className="annotation-handle" 290 > 291 @{authorHandle} 292 </a> 293 )} 294 </div> 295 <div className="annotation-time">{formatDate(data.createdAt)}</div> 296 </div> 297 </div> 298 <div className="annotation-header-right"> 299 <div style={{ display: "flex", gap: "4px" }}> 300 {hasEditHistory && !data.color && !data.description && ( 301 <button 302 className="annotation-action action-icon-only" 303 onClick={fetchHistory} 304 title="View Edit History" 305 > 306 <Clock size={16} /> 307 </button> 308 )} 309 310 {isOwner && ( 311 <> 312 {!data.color && !data.description && ( 313 <button 314 className="annotation-action action-icon-only" 315 onClick={() => setIsEditing(!isEditing)} 316 title="Edit" 317 > 318 <Edit2 size={16} /> 319 </button> 320 )} 321 <button 322 className="annotation-action action-icon-only" 323 onClick={handleDelete} 324 disabled={deleting} 325 title="Delete" 326 > 327 <TrashIcon size={16} /> 328 </button> 329 </> 330 )} 331 </div> 332 </div> 333 </header> 334 335 {showHistory && ( 336 <div className="history-panel"> 337 <div className="history-header"> 338 <h4 className="history-title">Edit History</h4> 339 <button 340 className="history-close-btn" 341 onClick={() => setShowHistory(false)} 342 title="Close History" 343 > 344 <X size={14} /> 345 </button> 346 </div> 347 {loadingHistory ? ( 348 <div className="history-status">Loading history...</div> 349 ) : editHistory.length === 0 ? ( 350 <div className="history-status">No edit history found.</div> 351 ) : ( 352 <ul className="history-list"> 353 {editHistory.map((edit) => ( 354 <li key={edit.id} className="history-item"> 355 <div className="history-date"> 356 {new Date(edit.editedAt).toLocaleString()} 357 </div> 358 <div className="history-content">{edit.previousContent}</div> 359 </li> 360 ))} 361 </ul> 362 )} 363 </div> 364 )} 365 366 <div className="annotation-content"> 367 <a 368 href={data.url} 369 target="_blank" 370 rel="noopener noreferrer" 371 className="annotation-source" 372 > 373 {truncateUrl(data.url)} 374 {data.title && ( 375 <span className="annotation-source-title"> {data.title}</span> 376 )} 377 </a> 378 379 {highlightedText && ( 380 <a 381 href={fragmentUrl} 382 target="_blank" 383 rel="noopener noreferrer" 384 className="annotation-highlight" 385 style={{ 386 borderLeftColor: data.color || "var(--accent)", 387 }} 388 > 389 <mark>"{highlightedText}"</mark> 390 </a> 391 )} 392 393 {isEditing ? ( 394 <div className="mt-3"> 395 <textarea 396 value={editText} 397 onChange={(e) => setEditText(e.target.value)} 398 className="reply-input" 399 rows={3} 400 style={{ marginBottom: "8px" }} 401 /> 402 <input 403 type="text" 404 className="reply-input" 405 placeholder="Tags (comma separated)..." 406 value={editTags} 407 onChange={(e) => setEditTags(e.target.value)} 408 style={{ marginBottom: "8px" }} 409 /> 410 <div className="action-buttons-end"> 411 <button 412 onClick={() => setIsEditing(false)} 413 className="btn btn-ghost" 414 > 415 Cancel 416 </button> 417 <button 418 onClick={handleSaveEdit} 419 disabled={saving} 420 className="btn btn-primary btn-sm" 421 > 422 {saving ? ( 423 "Saving..." 424 ) : ( 425 <> 426 <Save size={14} /> Save 427 </> 428 )} 429 </button> 430 </div> 431 </div> 432 ) : ( 433 data.text && <p className="annotation-text">{data.text}</p> 434 )} 435 436 {data.tags?.length > 0 && ( 437 <div className="annotation-tags"> 438 {data.tags.map((tag, i) => ( 439 <Link 440 key={i} 441 to={`/?tag=${encodeURIComponent(tag)}`} 442 className="annotation-tag" 443 > 444 #{tag} 445 </Link> 446 ))} 447 </div> 448 )} 449 </div> 450 451 <footer className="annotation-actions"> 452 <div className="annotation-actions-left"> 453 <button 454 className={`annotation-action ${isLiked ? "liked" : ""}`} 455 onClick={handleLike} 456 > 457 <HeartIcon filled={isLiked} size={16} /> 458 {likeCount > 0 && <span>{likeCount}</span>} 459 </button> 460 <button 461 className={`annotation-action ${showReplies ? "active" : ""}`} 462 onClick={async () => { 463 if (!showReplies && replies.length === 0) { 464 try { 465 const res = await getReplies(data.uri); 466 if (res.items) setReplies(res.items); 467 } catch (err) { 468 console.error("Failed to load replies:", err); 469 } 470 } 471 setShowReplies(!showReplies); 472 }} 473 > 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={() => { 486 if (!user) { 487 login(); 488 return; 489 } 490 if (onAddToCollection) onAddToCollection(); 491 }} 492 > 493 <Folder size={16} /> 494 <span>Collect</span> 495 </button> 496 </div> 497 </footer> 498 499 {showReplies && ( 500 <div className="inline-replies"> 501 <ReplyList 502 replies={replies} 503 rootUri={data.uri} 504 user={user} 505 onReply={(reply) => setReplyingTo(reply)} 506 onDelete={async (reply) => { 507 if (!confirm("Delete this reply?")) return; 508 try { 509 await deleteReply(reply.id || reply.uri); 510 const res = await getReplies(data.uri); 511 if (res.items) { 512 setReplies(res.items); 513 setReplyCount(res.items.length); 514 } 515 } catch (err) { 516 alert("Failed to delete: " + err.message); 517 } 518 }} 519 isInline={true} 520 /> 521 522 <div className="reply-form"> 523 {replyingTo && ( 524 <div 525 style={{ 526 display: "flex", 527 alignItems: "center", 528 gap: "8px", 529 marginBottom: "8px", 530 fontSize: "0.85rem", 531 color: "var(--text-secondary)", 532 }} 533 > 534 <span> 535 Replying to @ 536 {(replyingTo.creator || replyingTo.author)?.handle || 537 "unknown"} 538 </span> 539 <button 540 onClick={() => setReplyingTo(null)} 541 style={{ 542 background: "none", 543 border: "none", 544 color: "var(--text-tertiary)", 545 cursor: "pointer", 546 padding: "2px 6px", 547 }} 548 > 549 × 550 </button> 551 </div> 552 )} 553 <textarea 554 className="reply-input" 555 placeholder={ 556 replyingTo 557 ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 558 : "Write a reply..." 559 } 560 value={replyText} 561 onChange={(e) => setReplyText(e.target.value)} 562 onFocus={(e) => { 563 if (!user) { 564 e.target.blur(); 565 login(); 566 } 567 }} 568 rows={2} 569 /> 570 <div className="action-buttons-end"> 571 <button 572 className="btn btn-primary btn-sm" 573 disabled={posting || !replyText.trim()} 574 onClick={() => { 575 if (!user) { 576 login(); 577 return; 578 } 579 handlePostReply(replyingTo); 580 }} 581 > 582 {posting ? "Posting..." : "Reply"} 583 </button> 584 </div> 585 </div> 586 </div> 587 )} 588 </article> 589 ); 590} 591 592export function HighlightCard({ highlight, onDelete, onAddToCollection }) { 593 const { user, login } = useAuth(); 594 const data = normalizeHighlight(highlight); 595 const highlightedText = 596 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 597 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 598 const isOwner = user?.did && data.author?.did === user.did; 599 const [isEditing, setIsEditing] = useState(false); 600 const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 601 const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 602 603 const handleSaveEdit = async () => { 604 try { 605 const tagList = editTags 606 .split(",") 607 .map((t) => t.trim()) 608 .filter(Boolean); 609 610 await updateHighlight(data.uri, editColor, tagList); 611 setIsEditing(false); 612 613 if (highlight.color) highlight.color = editColor; 614 if (highlight.tags) highlight.tags = tagList; 615 else highlight.value = { ...highlight.value, tags: tagList }; 616 } catch (err) { 617 alert("Failed to update: " + err.message); 618 } 619 }; 620 621 const formatDate = (dateString, simple = true) => { 622 if (!dateString) return ""; 623 const date = new Date(dateString); 624 const now = new Date(); 625 const diff = now - date; 626 const minutes = Math.floor(diff / 60000); 627 const hours = Math.floor(diff / 3600000); 628 const days = Math.floor(diff / 86400000); 629 if (minutes < 1) return "just now"; 630 if (minutes < 60) return `${minutes}m`; 631 if (hours < 24) return `${hours}h`; 632 if (days < 7) return `${days}d`; 633 if (simple) 634 return date.toLocaleDateString("en-US", { 635 month: "short", 636 day: "numeric", 637 }); 638 return date.toLocaleString(); 639 }; 640 641 return ( 642 <article className="card annotation-card"> 643 <header className="annotation-header"> 644 <div className="annotation-header-left"> 645 <Link 646 to={data.author?.did ? `/profile/${data.author.did}` : "#"} 647 className="annotation-avatar-link" 648 > 649 <div className="annotation-avatar"> 650 {data.author?.avatar ? ( 651 <img src={data.author.avatar} alt="avatar" /> 652 ) : ( 653 <span>??</span> 654 )} 655 </div> 656 </Link> 657 <div className="annotation-meta"> 658 <Link to="#" className="annotation-author-link"> 659 <span className="annotation-author"> 660 {data.author?.displayName || "Unknown"} 661 </span> 662 </Link> 663 <div className="annotation-time">{formatDate(data.createdAt)}</div> 664 {data.author?.handle && ( 665 <a 666 href={`https://bsky.app/profile/${data.author.handle}`} 667 target="_blank" 668 rel="noopener noreferrer" 669 className="annotation-handle" 670 > 671 @{data.author.handle} 672 </a> 673 )} 674 </div> 675 </div> 676 677 <div className="annotation-header-right"> 678 <div style={{ display: "flex", gap: "4px" }}> 679 {isOwner && ( 680 <> 681 <button 682 className="annotation-action action-icon-only" 683 onClick={() => setIsEditing(!isEditing)} 684 title="Edit Color" 685 > 686 <Edit2 size={16} /> 687 </button> 688 <button 689 className="annotation-action action-icon-only" 690 onClick={(e) => { 691 e.preventDefault(); 692 onDelete && onDelete(highlight.id || highlight.uri); 693 }} 694 > 695 <TrashIcon size={16} /> 696 </button> 697 </> 698 )} 699 </div> 700 </div> 701 </header> 702 703 <div className="annotation-content"> 704 <a 705 href={data.url} 706 target="_blank" 707 rel="noopener noreferrer" 708 className="annotation-source" 709 > 710 {truncateUrl(data.url)} 711 </a> 712 713 {highlightedText && ( 714 <a 715 href={fragmentUrl} 716 target="_blank" 717 rel="noopener noreferrer" 718 className="annotation-highlight" 719 style={{ 720 borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 721 }} 722 > 723 <mark>"{highlightedText}"</mark> 724 </a> 725 )} 726 727 {isEditing && ( 728 <div 729 className="mt-3" 730 style={{ 731 display: "flex", 732 gap: "8px", 733 alignItems: "center", 734 padding: "8px", 735 background: "var(--bg-secondary)", 736 borderRadius: "var(--radius-md)", 737 border: "1px solid var(--border)", 738 }} 739 > 740 <div 741 className="color-picker-compact" 742 style={{ 743 position: "relative", 744 width: "28px", 745 height: "28px", 746 flexShrink: 0, 747 }} 748 > 749 <div 750 style={{ 751 backgroundColor: editColor, 752 width: "100%", 753 height: "100%", 754 borderRadius: "50%", 755 border: "2px solid var(--bg-card)", 756 boxShadow: "0 0 0 1px var(--border)", 757 }} 758 /> 759 <input 760 type="color" 761 value={editColor} 762 onChange={(e) => setEditColor(e.target.value)} 763 style={{ 764 position: "absolute", 765 top: 0, 766 left: 0, 767 width: "100%", 768 height: "100%", 769 opacity: 0, 770 cursor: "pointer", 771 }} 772 title="Change Color" 773 /> 774 </div> 775 776 <input 777 type="text" 778 className="reply-input" 779 placeholder="e.g. tag1, tag2" 780 value={editTags} 781 onChange={(e) => setEditTags(e.target.value)} 782 style={{ 783 margin: 0, 784 flex: 1, 785 fontSize: "0.9rem", 786 padding: "6px 10px", 787 height: "32px", 788 border: "none", 789 background: "transparent", 790 }} 791 /> 792 793 <button 794 onClick={handleSaveEdit} 795 className="btn btn-primary btn-sm" 796 style={{ padding: "0 10px", height: "32px", minWidth: "auto" }} 797 title="Save" 798 > 799 <Save size={16} /> 800 </button> 801 </div> 802 )} 803 804 {data.tags?.length > 0 && ( 805 <div className="annotation-tags"> 806 {data.tags.map((tag, i) => ( 807 <Link 808 key={i} 809 to={`/?tag=${encodeURIComponent(tag)}`} 810 className="annotation-tag" 811 > 812 #{tag} 813 </Link> 814 ))} 815 </div> 816 )} 817 </div> 818 819 <footer className="annotation-actions"> 820 <div className="annotation-actions-left"> 821 <span 822 className="annotation-action" 823 style={{ 824 color: data.color || "#f59e0b", 825 background: "none", 826 paddingLeft: 0, 827 }} 828 > 829 <HighlightIcon size={14} /> Highlight 830 </span> 831 <ShareMenu 832 uri={data.uri} 833 text={data.title || data.description} 834 handle={data.author?.handle} 835 type="Highlight" 836 /> 837 <button 838 className="annotation-action" 839 onClick={() => { 840 if (!user) { 841 login(); 842 return; 843 } 844 if (onAddToCollection) onAddToCollection(); 845 }} 846 > 847 <Folder size={16} /> 848 <span>Collect</span> 849 </button> 850 </div> 851 </footer> 852 </article> 853 ); 854}