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