(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at ui-refactor 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}