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