Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at 5edfcb084786af01253bffa72de80c6af054eaed 674 lines 20 kB view raw
1import { useState, useEffect } from "react"; 2import { useAuth } from "../context/AuthContext"; 3import ReplyList from "./ReplyList"; 4import { Link } from "react-router-dom"; 5import RichText from "./RichText"; 6import { 7 normalizeAnnotation, 8 normalizeHighlight, 9 likeAnnotation, 10 unlikeAnnotation, 11 getReplies, 12 createReply, 13 deleteReply, 14 updateAnnotation, 15 updateHighlight, 16 getEditHistory, 17 deleteAnnotation, 18} from "../api/client"; 19import { 20 MessageSquare, 21 Heart, 22 Trash2, 23 Folder, 24 Edit2, 25 Save, 26 X, 27 Clock, 28} from "lucide-react"; 29import { HighlightIcon, TrashIcon } from "./Icons"; 30import ShareMenu from "./ShareMenu"; 31import UserMeta from "./UserMeta"; 32 33function buildTextFragmentUrl(baseUrl, selector) { 34 if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { 35 return baseUrl; 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 return baseUrl + "#" + fragment; 46} 47 48const truncateUrl = (url, maxLength = 50) => { 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 61function SembleBadge() { 62 return ( 63 <div className="semble-badge" title="Added using Semble"> 64 <span>via Semble</span> 65 <img src="/semble-logo.svg" alt="Semble" /> 66 </div> 67 ); 68} 69 70export default function AnnotationCard({ 71 annotation, 72 onDelete, 73 onAddToCollection, 74}) { 75 const { user, login } = useAuth(); 76 const data = normalizeAnnotation(annotation); 77 78 const [likeCount, setLikeCount] = useState(data.likeCount || 0); 79 const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); 80 const [deleting, setDeleting] = useState(false); 81 const [isEditing, setIsEditing] = useState(false); 82 const [editText, setEditText] = useState(data.text || ""); 83 const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 84 const [saving, setSaving] = useState(false); 85 const [showHistory, setShowHistory] = useState(false); 86 const [editHistory, setEditHistory] = useState([]); 87 const [loadingHistory, setLoadingHistory] = useState(false); 88 const [replies, setReplies] = useState([]); 89 const [replyCount, setReplyCount] = useState(data.replyCount || 0); 90 const [showReplies, setShowReplies] = useState(false); 91 const [replyingTo, setReplyingTo] = useState(null); 92 const [replyText, setReplyText] = useState(""); 93 const [posting, setPosting] = useState(false); 94 const [hasEditHistory, setHasEditHistory] = useState(false); 95 96 const isOwner = user?.did && data.author?.did === user.did; 97 const isSemble = data.uri?.includes("network.cosmik"); 98 const highlightedText = 99 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 100 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 101 102 useEffect(() => { 103 if (data.uri && !data.color && !data.description) { 104 getEditHistory(data.uri) 105 .then((history) => { 106 if (history?.length > 0) setHasEditHistory(true); 107 }) 108 .catch(() => {}); 109 } 110 }, [data.uri, data.color, data.description]); 111 112 const fetchHistory = async () => { 113 if (showHistory) { 114 setShowHistory(false); 115 return; 116 } 117 try { 118 setLoadingHistory(true); 119 setShowHistory(true); 120 const history = await getEditHistory(data.uri); 121 setEditHistory(history); 122 } catch (err) { 123 console.error("Failed to fetch history:", err); 124 } finally { 125 setLoadingHistory(false); 126 } 127 }; 128 129 const handlePostReply = async (parentReply) => { 130 if (!replyText.trim()) return; 131 try { 132 setPosting(true); 133 const parentUri = parentReply 134 ? parentReply.id || parentReply.uri 135 : data.uri; 136 const parentCid = parentReply 137 ? parentReply.cid 138 : annotation.cid || data.cid; 139 140 await createReply({ 141 parentUri, 142 parentCid: parentCid || "", 143 rootUri: data.uri, 144 rootCid: annotation.cid || data.cid || "", 145 text: replyText, 146 }); 147 148 setReplyText(""); 149 setReplyingTo(null); 150 151 const res = await getReplies(data.uri); 152 if (res.items) { 153 setReplies(res.items); 154 setReplyCount(res.items.length); 155 } 156 } catch (err) { 157 alert("Failed to post reply: " + err.message); 158 } finally { 159 setPosting(false); 160 } 161 }; 162 163 const handleSaveEdit = async () => { 164 try { 165 setSaving(true); 166 const tagList = editTags 167 .split(",") 168 .map((t) => t.trim()) 169 .filter(Boolean); 170 await updateAnnotation(data.uri, editText, tagList); 171 setIsEditing(false); 172 if (annotation.body) annotation.body.value = editText; 173 else if (annotation.text) annotation.text = editText; 174 if (annotation.tags) annotation.tags = tagList; 175 data.tags = tagList; 176 } catch (err) { 177 alert("Failed to update: " + err.message); 178 } finally { 179 setSaving(false); 180 } 181 }; 182 183 const handleLike = async () => { 184 if (!user) { 185 login(); 186 return; 187 } 188 try { 189 if (isLiked) { 190 setIsLiked(false); 191 setLikeCount((prev) => Math.max(0, prev - 1)); 192 await unlikeAnnotation(data.uri); 193 } else { 194 setIsLiked(true); 195 setLikeCount((prev) => prev + 1); 196 const cid = annotation.cid || data.cid || ""; 197 if (data.uri && cid) await likeAnnotation(data.uri, cid); 198 } 199 } catch { 200 setIsLiked(!isLiked); 201 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 202 } 203 }; 204 205 const handleDelete = async () => { 206 if (!confirm("Delete this annotation? This cannot be undone.")) return; 207 try { 208 setDeleting(true); 209 const parts = data.uri.split("/"); 210 const rkey = parts[parts.length - 1]; 211 await deleteAnnotation(rkey); 212 if (onDelete) onDelete(data.uri); 213 else window.location.reload(); 214 } catch (err) { 215 alert("Failed to delete: " + err.message); 216 } finally { 217 setDeleting(false); 218 } 219 }; 220 221 const loadReplies = async () => { 222 if (!showReplies && replies.length === 0) { 223 try { 224 const res = await getReplies(data.uri); 225 if (res.items) setReplies(res.items); 226 } catch (err) { 227 console.error("Failed to load replies:", err); 228 } 229 } 230 setShowReplies(!showReplies); 231 }; 232 233 const handleCollect = () => { 234 if (!user) { 235 login(); 236 return; 237 } 238 if (onAddToCollection) onAddToCollection(); 239 }; 240 241 return ( 242 <article className="card annotation-card"> 243 <header className="annotation-header"> 244 <div className="annotation-header-left"> 245 <UserMeta author={data.author} createdAt={data.createdAt} /> 246 </div> 247 <div className="annotation-header-right"> 248 {isSemble && <SembleBadge />} 249 {hasEditHistory && !data.color && !data.description && ( 250 <button 251 className="annotation-action action-icon-only" 252 onClick={fetchHistory} 253 title="View Edit History" 254 > 255 <Clock size={16} /> 256 </button> 257 )} 258 {isOwner && !isSemble && ( 259 <> 260 {!data.color && !data.description && ( 261 <button 262 className="annotation-action action-icon-only" 263 onClick={() => setIsEditing(!isEditing)} 264 title="Edit" 265 > 266 <Edit2 size={16} /> 267 </button> 268 )} 269 <button 270 className="annotation-action action-icon-only" 271 onClick={handleDelete} 272 disabled={deleting} 273 title="Delete" 274 > 275 <Trash2 size={16} /> 276 </button> 277 </> 278 )} 279 </div> 280 </header> 281 282 {showHistory && ( 283 <div className="history-panel"> 284 <div className="history-header"> 285 <h4 className="history-title">Edit History</h4> 286 <button 287 className="annotation-action action-icon-only" 288 onClick={() => setShowHistory(false)} 289 > 290 <X size={14} /> 291 </button> 292 </div> 293 {loadingHistory ? ( 294 <div className="history-status">Loading history...</div> 295 ) : editHistory.length === 0 ? ( 296 <div className="history-status">No edit history found.</div> 297 ) : ( 298 <ul className="history-list"> 299 {editHistory.map((edit) => ( 300 <li key={edit.id} className="history-item"> 301 <div className="history-date"> 302 {new Date(edit.editedAt).toLocaleString()} 303 </div> 304 <div className="history-content">{edit.previousContent}</div> 305 </li> 306 ))} 307 </ul> 308 )} 309 </div> 310 )} 311 312 <div className="annotation-content"> 313 <a 314 href={data.url} 315 target="_blank" 316 rel="noopener noreferrer" 317 className="annotation-source" 318 > 319 {truncateUrl(data.url)} 320 {data.title && ( 321 <span className="annotation-source-title"> · {data.title}</span> 322 )} 323 </a> 324 325 {highlightedText && ( 326 <a 327 href={fragmentUrl} 328 target="_blank" 329 rel="noopener noreferrer" 330 className="annotation-highlight" 331 style={{ borderLeftColor: data.color || "var(--accent)" }} 332 > 333 <mark>&ldquo;{highlightedText}&rdquo;</mark> 334 </a> 335 )} 336 337 {isEditing ? ( 338 <div className="edit-form"> 339 <textarea 340 value={editText} 341 onChange={(e) => setEditText(e.target.value)} 342 className="reply-input" 343 rows={3} 344 placeholder="Your annotation..." 345 /> 346 <input 347 type="text" 348 className="reply-input" 349 placeholder="Tags (comma separated)..." 350 value={editTags} 351 onChange={(e) => setEditTags(e.target.value)} 352 style={{ marginTop: "8px" }} 353 /> 354 <div className="action-buttons-end" style={{ marginTop: "8px" }}> 355 <button 356 onClick={() => setIsEditing(false)} 357 className="btn btn-ghost" 358 > 359 Cancel 360 </button> 361 <button 362 onClick={handleSaveEdit} 363 disabled={saving} 364 className="btn btn-primary" 365 > 366 {saving ? ( 367 "Saving..." 368 ) : ( 369 <> 370 <Save size={14} /> Save 371 </> 372 )} 373 </button> 374 </div> 375 </div> 376 ) : ( 377 <RichText text={data.text} facets={data.facets} /> 378 )} 379 380 {data.tags?.length > 0 && ( 381 <div className="annotation-tags"> 382 {data.tags.map((tag, i) => ( 383 <Link 384 key={i} 385 to={`/?tag=${encodeURIComponent(tag)}`} 386 className="annotation-tag" 387 > 388 #{tag} 389 </Link> 390 ))} 391 </div> 392 )} 393 </div> 394 395 <footer className="annotation-actions"> 396 <div className="annotation-actions-left"> 397 <button 398 className={`annotation-action ${isLiked ? "liked" : ""}`} 399 onClick={handleLike} 400 > 401 <Heart size={16} fill={isLiked ? "currentColor" : "none"} /> 402 {likeCount > 0 && <span>{likeCount}</span>} 403 </button> 404 405 <button 406 className={`annotation-action ${showReplies ? "active" : ""}`} 407 onClick={loadReplies} 408 > 409 <MessageSquare size={16} /> 410 <span>{replyCount > 0 ? replyCount : "Reply"}</span> 411 </button> 412 413 <ShareMenu 414 uri={data.uri} 415 text={data.title || data.url} 416 handle={data.author?.handle} 417 type="Annotation" 418 url={data.url} 419 /> 420 421 <button className="annotation-action" onClick={handleCollect}> 422 <Folder size={16} /> 423 <span>Collect</span> 424 </button> 425 </div> 426 </footer> 427 428 {showReplies && ( 429 <div className="inline-replies"> 430 <ReplyList 431 replies={replies} 432 rootUri={data.uri} 433 user={user} 434 onReply={(reply) => setReplyingTo(reply)} 435 onDelete={async (reply) => { 436 if (!confirm("Delete this reply?")) return; 437 try { 438 await deleteReply(reply.id || reply.uri); 439 const res = await getReplies(data.uri); 440 if (res.items) { 441 setReplies(res.items); 442 setReplyCount(res.items.length); 443 } 444 } catch (err) { 445 alert("Failed to delete: " + err.message); 446 } 447 }} 448 isInline={true} 449 /> 450 451 <div className="reply-form"> 452 {replyingTo && ( 453 <div className="replying-to-banner"> 454 <span> 455 Replying to @ 456 {(replyingTo.creator || replyingTo.author)?.handle || 457 "unknown"} 458 </span> 459 <button 460 onClick={() => setReplyingTo(null)} 461 className="cancel-reply" 462 > 463 × 464 </button> 465 </div> 466 )} 467 <textarea 468 className="reply-input" 469 placeholder={ 470 replyingTo 471 ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 472 : "Write a reply..." 473 } 474 value={replyText} 475 onChange={(e) => setReplyText(e.target.value)} 476 rows={2} 477 /> 478 <div className="reply-form-actions"> 479 <button 480 className="btn btn-primary" 481 disabled={posting || !replyText.trim()} 482 onClick={() => { 483 if (!user) { 484 login(); 485 return; 486 } 487 handlePostReply(replyingTo); 488 }} 489 > 490 {posting ? "Posting..." : "Reply"} 491 </button> 492 </div> 493 </div> 494 </div> 495 )} 496 </article> 497 ); 498} 499 500export function HighlightCard({ 501 highlight, 502 onDelete, 503 onAddToCollection, 504 onUpdate, 505}) { 506 const { user, login } = useAuth(); 507 const data = normalizeHighlight(highlight); 508 const highlightedText = 509 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 510 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 511 const isOwner = user?.did && data.author?.did === user.did; 512 const isSemble = data.uri?.includes("network.cosmik"); 513 514 const [isEditing, setIsEditing] = useState(false); 515 const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 516 const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 517 518 const handleSaveEdit = async () => { 519 try { 520 const tagList = editTags 521 .split(",") 522 .map((t) => t.trim()) 523 .filter(Boolean); 524 await updateHighlight(data.uri, editColor, tagList); 525 setIsEditing(false); 526 if (typeof onUpdate === "function") { 527 onUpdate({ ...highlight, color: editColor, tags: tagList }); 528 } 529 } catch (err) { 530 alert("Failed to update: " + err.message); 531 } 532 }; 533 534 const handleCollect = () => { 535 if (!user) { 536 login(); 537 return; 538 } 539 if (onAddToCollection) onAddToCollection(); 540 }; 541 542 return ( 543 <article className="card annotation-card"> 544 <header className="annotation-header"> 545 <div className="annotation-header-left"> 546 <UserMeta author={data.author} createdAt={data.createdAt} /> 547 </div> 548 <div className="annotation-header-right"> 549 {isSemble && ( 550 <div className="semble-badge" title="Added using Semble"> 551 <span>via Semble</span> 552 <img src="/semble-logo.svg" alt="Semble" /> 553 </div> 554 )} 555 {isOwner && ( 556 <> 557 <button 558 className="annotation-action action-icon-only" 559 onClick={() => setIsEditing(!isEditing)} 560 title="Edit Color" 561 > 562 <Edit2 size={16} /> 563 </button> 564 <button 565 className="annotation-action action-icon-only" 566 onClick={(e) => { 567 e.preventDefault(); 568 onDelete && onDelete(highlight.id || highlight.uri); 569 }} 570 title="Delete" 571 > 572 <TrashIcon size={16} /> 573 </button> 574 </> 575 )} 576 </div> 577 </header> 578 579 <div className="annotation-content"> 580 <a 581 href={data.url} 582 target="_blank" 583 rel="noopener noreferrer" 584 className="annotation-source" 585 > 586 {truncateUrl(data.url)} 587 </a> 588 589 {highlightedText && ( 590 <a 591 href={fragmentUrl} 592 target="_blank" 593 rel="noopener noreferrer" 594 className="annotation-highlight" 595 style={{ 596 borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 597 }} 598 > 599 <mark>&ldquo;{highlightedText}&rdquo;</mark> 600 </a> 601 )} 602 603 {isEditing && ( 604 <div className="color-edit-form"> 605 <div className="color-picker-wrapper"> 606 <div 607 className="color-preview" 608 style={{ backgroundColor: editColor }} 609 /> 610 <input 611 type="color" 612 value={editColor} 613 onChange={(e) => setEditColor(e.target.value)} 614 className="color-input" 615 /> 616 </div> 617 <input 618 type="text" 619 className="reply-input" 620 placeholder="Tags (comma separated)" 621 value={editTags} 622 onChange={(e) => setEditTags(e.target.value)} 623 style={{ flex: 1, margin: 0 }} 624 /> 625 <button 626 onClick={handleSaveEdit} 627 className="btn btn-primary" 628 style={{ padding: "0 12px", height: "32px" }} 629 > 630 <Save size={16} /> 631 </button> 632 </div> 633 )} 634 635 {data.tags?.length > 0 && ( 636 <div className="annotation-tags"> 637 {data.tags.map((tag, i) => ( 638 <Link 639 key={i} 640 to={`/?tag=${encodeURIComponent(tag)}`} 641 className="annotation-tag" 642 > 643 #{tag} 644 </Link> 645 ))} 646 </div> 647 )} 648 </div> 649 650 <footer className="annotation-actions"> 651 <div className="annotation-actions-left"> 652 <span 653 className="annotation-action" 654 style={{ color: data.color || "#f59e0b", cursor: "default" }} 655 > 656 <HighlightIcon size={14} /> Highlight 657 </span> 658 659 <ShareMenu 660 uri={data.uri} 661 text={data.title || data.description} 662 handle={data.author?.handle} 663 type="Highlight" 664 /> 665 666 <button className="annotation-action" onClick={handleCollect}> 667 <Folder size={16} /> 668 <span>Collect</span> 669 </button> 670 </div> 671 </footer> 672 </article> 673 ); 674}