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