Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 876 lines 32 kB view raw
1import React, { useState } from "react"; 2import { formatDistanceToNow } from "date-fns"; 3import RichText from "./RichText"; 4import MoreMenu from "./MoreMenu"; 5import type { MoreMenuItem } from "./MoreMenu"; 6import { 7 MessageSquare, 8 Heart, 9 ExternalLink, 10 FolderPlus, 11 Trash2, 12 Edit3, 13 Globe, 14 ShieldBan, 15 VolumeX, 16 Flag, 17 EyeOff, 18 Eye, 19 Tag, 20 Send, 21 X, 22} from "lucide-react"; 23import ShareMenu from "../modals/ShareMenu"; 24import AddToCollectionModal from "../modals/AddToCollectionModal"; 25import ExternalLinkModal from "../modals/ExternalLinkModal"; 26import ReportModal from "../modals/ReportModal"; 27import EditItemModal from "../modals/EditItemModal"; 28import EditHistoryModal from "../modals/EditHistoryModal"; 29import { clsx } from "clsx"; 30import { 31 likeItem, 32 unlikeItem, 33 deleteItem, 34 blockUser, 35 muteUser, 36 convertHighlightToAnnotation, 37} from "../../api/client"; 38import { $user } from "../../store/auth"; 39import { $preferences } from "../../store/preferences"; 40import { useStore } from "@nanostores/react"; 41import type { 42 AnnotationItem, 43 ContentLabel, 44 LabelVisibility, 45} from "../../types"; 46import { Link } from "react-router-dom"; 47import { Avatar } from "../ui"; 48import CollectionIcon from "./CollectionIcon"; 49import ProfileHoverCard from "./ProfileHoverCard"; 50 51const LABEL_DESCRIPTIONS: Record<string, string> = { 52 sexual: "Sexual Content", 53 nudity: "Nudity", 54 violence: "Violence", 55 gore: "Graphic Content", 56 spam: "Spam", 57 misleading: "Misleading", 58}; 59 60function getContentWarning( 61 labels?: ContentLabel[], 62 prefs?: { 63 labelPreferences: { 64 labelerDid: string; 65 label: string; 66 visibility: LabelVisibility; 67 }[]; 68 }, 69): { 70 label: string; 71 description: string; 72 visibility: LabelVisibility; 73 isAccountWide: boolean; 74} | null { 75 if (!labels || labels.length === 0) return null; 76 const priority = [ 77 "gore", 78 "violence", 79 "nudity", 80 "sexual", 81 "misleading", 82 "spam", 83 ]; 84 for (const p of priority) { 85 const match = labels.find((l) => l.val === p); 86 if (match) { 87 const pref = prefs?.labelPreferences.find( 88 (lp) => lp.label === p && lp.labelerDid === match.src, 89 ); 90 const visibility: LabelVisibility = pref?.visibility || "warn"; 91 if (visibility === "ignore") continue; 92 return { 93 label: p, 94 description: LABEL_DESCRIPTIONS[p] || p, 95 visibility, 96 isAccountWide: match.scope === "account", 97 }; 98 } 99 } 100 return null; 101} 102 103interface CardProps { 104 item: AnnotationItem; 105 onDelete?: (uri: string) => void; 106 onUpdate?: (item: AnnotationItem) => void; 107 hideShare?: boolean; 108 layout?: "list" | "mosaic"; 109} 110 111export default function Card({ 112 item: initialItem, 113 onDelete, 114 onUpdate, 115 hideShare, 116 layout = "list", 117}: CardProps) { 118 const [item, setItem] = useState(initialItem); 119 const user = useStore($user); 120 const preferences = useStore($preferences); 121 const isAuthor = user && item.author?.did === user.did; 122 123 const [liked, setLiked] = useState(!!item.viewer?.like); 124 const [likes, setLikes] = useState(item.likeCount || 0); 125 const [showCollectionModal, setShowCollectionModal] = useState(false); 126 const [showExternalLinkModal, setShowExternalLinkModal] = useState(false); 127 const [externalLinkUrl, setExternalLinkUrl] = useState<string | null>(null); 128 const [showReportModal, setShowReportModal] = useState(false); 129 const [showEditModal, setShowEditModal] = useState(false); 130 const [showEditHistory, setShowEditHistory] = useState(false); 131 const [contentRevealed, setContentRevealed] = useState(false); 132 const [showConvertInput, setShowConvertInput] = useState(false); 133 const [convertText, setConvertText] = useState(""); 134 const [converting, setConverting] = useState(false); 135 const [ogData, setOgData] = useState<{ 136 title?: string; 137 description?: string; 138 image?: string; 139 icon?: string; 140 } | null>(() => { 141 if (initialItem.motivation !== "bookmarking") return null; 142 const url = initialItem.target?.source || initialItem.source; 143 if (!url) return null; 144 try { 145 const cached = sessionStorage.getItem(`og:${url}`); 146 return cached ? JSON.parse(cached) : null; 147 } catch { 148 return null; 149 } 150 }); 151 const [imgError, setImgError] = useState(false); 152 const [iconError, setIconError] = useState(false); 153 154 const contentWarning = getContentWarning(item.labels, preferences); 155 156 React.useEffect(() => { 157 setItem(initialItem); 158 }, [initialItem]); 159 160 React.useEffect(() => { 161 setLiked(!!item.viewer?.like); 162 setLikes(item.likeCount || 0); 163 }, [item.viewer?.like, item.likeCount]); 164 165 const type = 166 item.motivation === "highlighting" 167 ? "highlight" 168 : item.motivation === "bookmarking" 169 ? "bookmark" 170 : "annotation"; 171 172 const isSemble = 173 item.uri?.includes("network.cosmik") || item.uri?.includes("semble"); 174 175 const safeUrlHostname = (url: string | null | undefined) => { 176 if (!url) return null; 177 try { 178 return new URL(url).hostname; 179 } catch { 180 return null; 181 } 182 }; 183 184 const pageUrl = item.target?.source || item.source; 185 const isBookmark = type === "bookmark"; 186 187 React.useEffect(() => { 188 if (isBookmark && item.uri && !ogData && pageUrl) { 189 const fetchMetadata = async () => { 190 try { 191 const res = await fetch( 192 `/api/url-metadata?url=${encodeURIComponent(pageUrl)}`, 193 ); 194 if (res.ok) { 195 const data = await res.json(); 196 setOgData(data); 197 try { 198 sessionStorage.setItem(`og:${pageUrl}`, JSON.stringify(data)); 199 } catch { 200 /* quota exceeded */ 201 } 202 } 203 } catch (e) { 204 console.error("Failed to fetch metadata", e); 205 } 206 }; 207 fetchMetadata(); 208 } 209 }, [isBookmark, item.uri, pageUrl, ogData]); 210 211 if (contentWarning?.visibility === "hide") return null; 212 213 const handleLike = async () => { 214 const prev = { liked, likes }; 215 setLiked(!liked); 216 setLikes((l) => (liked ? l - 1 : l + 1)); 217 218 const success = liked 219 ? await unlikeItem(item.uri) 220 : await likeItem(item.uri, item.cid); 221 222 if (!success) { 223 setLiked(prev.liked); 224 setLikes(prev.likes); 225 } 226 }; 227 228 const handleDelete = async () => { 229 if (window.confirm("Delete this item?")) { 230 const success = await deleteItem(item.uri, type); 231 if (success && onDelete) onDelete(item.uri); 232 } 233 }; 234 235 const handleConvert = async () => { 236 if (!convertText.trim() || converting) return; 237 setConverting(true); 238 const pageUrl = item.target?.source || item.source || ""; 239 const res = await convertHighlightToAnnotation( 240 item.uri, 241 pageUrl, 242 convertText.trim(), 243 item.target?.selector, 244 item.target?.title, 245 ); 246 setConverting(false); 247 if (res.success) { 248 setShowConvertInput(false); 249 setConvertText(""); 250 if (onDelete) onDelete(item.uri); 251 } 252 }; 253 254 const handleExternalClick = (e: React.MouseEvent, url: string) => { 255 e.preventDefault(); 256 e.stopPropagation(); 257 258 try { 259 const hostname = safeUrlHostname(url); 260 if (hostname) { 261 if ( 262 hostname === "margin.at" || 263 hostname.endsWith(".margin.at") || 264 hostname === "semble.so" || 265 hostname.endsWith(".semble.so") 266 ) { 267 window.open(url, "_blank", "noopener,noreferrer"); 268 return; 269 } 270 271 if ($preferences.get().disableExternalLinkWarning) { 272 window.open(url, "_blank", "noopener,noreferrer"); 273 return; 274 } 275 276 const skipped = $preferences.get().externalLinkSkippedHostnames; 277 if (skipped.includes(hostname)) { 278 window.open(url, "_blank", "noopener,noreferrer"); 279 return; 280 } 281 } 282 } catch (err) { 283 if (err instanceof Error && err.name !== "TypeError") { 284 console.debug("Failed to check skipped hostname:", err); 285 } 286 } 287 288 setExternalLinkUrl(url); 289 setShowExternalLinkModal(true); 290 }; 291 292 const timestamp = item.createdAt 293 ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false }) 294 .replace("less than a minute", "just now") 295 .replace("about ", "") 296 .replace(" hours", "h") 297 .replace(" hour", "h") 298 .replace(" minutes", "m") 299 .replace(" minute", "m") 300 .replace(" days", "d") 301 .replace(" day", "d") 302 : ""; 303 304 const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`; 305 306 const pageTitle = 307 item.target?.title || 308 item.title || 309 (pageUrl ? safeUrlHostname(pageUrl) : null); 310 const displayUrl = pageUrl 311 ? (() => { 312 const clean = pageUrl 313 .replace(/^https?:\/\//, "") 314 .replace(/^www\./, "") 315 .replace(/\/$/, ""); 316 return clean.length > 60 ? clean.slice(0, 57) + "..." : clean; 317 })() 318 : null; 319 320 const decodeHTMLEntities = (text: string) => { 321 const textarea = document.createElement("textarea"); 322 textarea.innerHTML = text; 323 return textarea.value; 324 }; 325 326 const displayTitle = decodeHTMLEntities( 327 item.title || ogData?.title || pageTitle || "Untitled Bookmark", 328 ); 329 const displayDescription = 330 item.description || ogData?.description 331 ? decodeHTMLEntities(item.description || ogData?.description || "") 332 : undefined; 333 const displayImage = ogData?.image; 334 335 return ( 336 <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative"> 337 {(item.collection || (item.context && item.context.length > 0)) && ( 338 <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap"> 339 {item.addedBy && item.addedBy.did !== item.author?.did ? ( 340 <> 341 <ProfileHoverCard did={item.addedBy.did}> 342 <Link 343 to={`/profile/${item.addedBy.did}`} 344 className="flex items-center gap-1.5 font-medium hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 345 > 346 <Avatar 347 did={item.addedBy.did} 348 avatar={item.addedBy.avatar} 349 size="xs" 350 /> 351 <span> 352 {item.addedBy.displayName || `@${item.addedBy.handle}`} 353 </span> 354 </Link> 355 </ProfileHoverCard> 356 <span>added to</span> 357 </> 358 ) : ( 359 <span>Added to</span> 360 )} 361 362 {item.context && item.context.length > 0 ? ( 363 item.context.map((col, index) => ( 364 <React.Fragment key={col.uri}> 365 {index > 0 && index < item.context!.length - 1 && ( 366 <span className="text-surface-300 dark:text-surface-600"> 367 , 368 </span> 369 )} 370 {index > 0 && index === item.context!.length - 1 && ( 371 <span>and</span> 372 )} 373 <Link 374 to={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`} 375 className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 376 > 377 <CollectionIcon icon={col.icon} size={14} /> 378 <span className="font-medium">{col.name}</span> 379 </Link> 380 </React.Fragment> 381 )) 382 ) : ( 383 <Link 384 to={`/${item.addedBy?.handle || ""}/collection/${(item.collection!.uri || "").split("/").pop()}`} 385 className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 386 > 387 <CollectionIcon icon={item.collection!.icon} size={14} /> 388 <span className="font-medium">{item.collection!.name}</span> 389 </Link> 390 )} 391 </div> 392 )} 393 394 <div className="flex items-start gap-3"> 395 <ProfileHoverCard did={item.author?.did}> 396 <Link to={`/profile/${item.author?.did}`} className="shrink-0"> 397 <div className="rounded-full overflow-hidden"> 398 <div 399 className={clsx( 400 "transition-all", 401 contentWarning?.isAccountWide && 402 !contentRevealed && 403 "blur-md", 404 )} 405 > 406 <Avatar 407 did={item.author?.did} 408 avatar={item.author?.avatar} 409 size="md" 410 /> 411 </div> 412 </div> 413 </Link> 414 </ProfileHoverCard> 415 416 <div className="flex-1 min-w-0"> 417 <div className="flex items-center gap-1.5 flex-wrap"> 418 <ProfileHoverCard did={item.author?.did}> 419 <Link 420 to={`/profile/${item.author?.did}`} 421 className="font-semibold text-surface-900 dark:text-white text-[15px] hover:underline" 422 > 423 {item.author?.displayName || item.author?.handle} 424 </Link> 425 </ProfileHoverCard> 426 <span className="text-surface-400 dark:text-surface-500 text-sm"> 427 @{item.author?.handle} 428 </span> 429 <span className="text-surface-300 dark:text-surface-600">·</span> 430 <span className="text-surface-400 dark:text-surface-500 text-sm"> 431 {timestamp} 432 {item.editedAt && ( 433 <button 434 onClick={(e) => { 435 e.preventDefault(); 436 e.stopPropagation(); 437 setShowEditHistory(true); 438 }} 439 className="ml-1 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-400 hover:underline cursor-pointer" 440 title={`Edited ${new Date(item.editedAt).toLocaleString()}`} 441 > 442 (edited) 443 </button> 444 )} 445 </span> 446 447 {isSemble && 448 (() => { 449 const uri = item.uri || ""; 450 const parts = uri.replace("at://", "").split("/"); 451 const userHandle = item.author?.handle || parts[0] || ""; 452 const rkey = parts[2] || ""; 453 const targetUrl = item.target?.source || item.source || ""; 454 let sembleUrl = `https://semble.so/profile/${userHandle}`; 455 if (uri.includes("network.cosmik.collection")) 456 sembleUrl = `https://semble.so/profile/${userHandle}/collections/${rkey}`; 457 else if (uri.includes("network.cosmik.card") && targetUrl) 458 sembleUrl = `https://semble.so/url?id=${encodeURIComponent(targetUrl)}`; 459 return ( 460 <span className="relative inline-flex items-center"> 461 <span className="text-surface-300 dark:text-surface-600"> 462 · 463 </span> 464 <button 465 onClick={(e) => handleExternalClick(e, sembleUrl)} 466 className="group/semble relative inline-flex items-center ml-1 cursor-pointer" 467 > 468 <img 469 src="/semble-logo.svg" 470 alt="Semble" 471 className="h-3.5" 472 /> 473 <span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 rounded-lg bg-surface-800 dark:bg-surface-700 text-white text-[11px] font-medium whitespace-nowrap opacity-0 group-hover/semble:opacity-100 transition-opacity shadow-lg"> 474 Open in Semble 475 <span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800 dark:border-t-surface-700" /> 476 </span> 477 </button> 478 </span> 479 ); 480 })()} 481 </div> 482 483 {pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && ( 484 <a 485 href={pageUrl} 486 target="_blank" 487 rel="noopener noreferrer" 488 onClick={(e) => handleExternalClick(e, pageUrl)} 489 className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5 max-w-full" 490 > 491 <ExternalLink size={10} className="flex-shrink-0" /> 492 <span className="truncate">{displayUrl}</span> 493 </a> 494 )} 495 </div> 496 </div> 497 498 <div 499 className={clsx( 500 "mt-3 relative", 501 layout === "mosaic" ? "" : "ml-[52px]", 502 )} 503 > 504 {contentWarning && !contentRevealed && ( 505 <div className="absolute inset-0 z-10 rounded-lg bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center gap-2 py-4"> 506 <div className="flex items-center gap-2 text-surface-500 dark:text-surface-400"> 507 <EyeOff size={16} /> 508 <span className="text-sm font-medium"> 509 {contentWarning.description} 510 </span> 511 </div> 512 <button 513 onClick={() => setContentRevealed(true)} 514 className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600 transition-colors" 515 > 516 <Eye size={12} /> 517 Show 518 </button> 519 </div> 520 )} 521 {contentWarning && contentRevealed && ( 522 <button 523 onClick={() => setContentRevealed(false)} 524 className="flex items-center gap-1.5 mb-2 px-2.5 py-1 text-xs font-medium rounded-lg bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors" 525 > 526 <EyeOff size={12} /> 527 Hide Content 528 </button> 529 )} 530 {isBookmark && ( 531 <div 532 onClick={(e) => { 533 e.preventDefault(); 534 if (pageUrl) handleExternalClick(e, pageUrl); 535 }} 536 role="button" 537 tabIndex={0} 538 className={clsx( 539 "flex bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden cursor-pointer", 540 layout === "mosaic" 541 ? "flex-col items-stretch" 542 : "flex-row items-stretch", 543 )} 544 > 545 {displayImage && !imgError && ( 546 <div 547 className={clsx( 548 "shrink-0 bg-surface-200 dark:bg-surface-700 relative", 549 layout === "mosaic" 550 ? "w-full aspect-video border-b border-surface-200 dark:border-surface-700" 551 : "w-[140px] sm:w-[180px] border-r border-surface-200 dark:border-surface-700", 552 )} 553 > 554 <div className="absolute inset-0 flex items-center justify-center overflow-hidden"> 555 <img 556 src={displayImage} 557 alt={displayTitle || "Link preview"} 558 className="h-full w-full object-cover" 559 onError={() => setImgError(true)} 560 /> 561 </div> 562 </div> 563 )} 564 <div 565 className={clsx( 566 "p-3 min-w-0 flex flex-col font-sans", 567 layout === "mosaic" ? "w-full" : "flex-1 justify-center", 568 )} 569 > 570 <h3 className="font-semibold text-surface-900 dark:text-white text-sm leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-1.5 transition-colors line-clamp-2"> 571 {displayTitle} 572 </h3> 573 574 {displayDescription && ( 575 <p className="text-surface-600 dark:text-surface-400 text-xs leading-relaxed mb-2 line-clamp-2"> 576 {displayDescription} 577 </p> 578 )} 579 580 <div className="flex items-center gap-2 text-[11px] text-surface-500 dark:text-surface-500 mt-auto"> 581 <div className="w-4 h-4 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden"> 582 {ogData?.icon && !iconError ? ( 583 <img 584 src={ogData.icon} 585 alt="" 586 onError={() => setIconError(true)} 587 className="w-3 h-3 object-contain" 588 /> 589 ) : ( 590 <Globe size={9} /> 591 )} 592 </div> 593 <span className="truncate max-w-[200px]"> 594 {displayUrl || pageUrl} 595 </span> 596 </div> 597 </div> 598 </div> 599 )} 600 601 {item.target?.selector?.exact && ( 602 <blockquote 603 className={clsx( 604 "pl-4 py-2 border-l-[3px] mb-3 text-[15px] italic text-surface-600 dark:text-surface-300 rounded-r-lg hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors", 605 !item.color && 606 type === "highlight" && 607 "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20", 608 item.color === "yellow" && 609 "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20", 610 item.color === "green" && 611 "border-green-400 bg-green-50/50 dark:bg-green-900/20", 612 item.color === "red" && 613 "border-red-400 bg-red-50/50 dark:bg-red-900/20", 614 item.color === "blue" && 615 "border-blue-400 bg-blue-50/50 dark:bg-blue-900/20", 616 !item.color && 617 type !== "highlight" && 618 "border-surface-300 dark:border-surface-600", 619 )} 620 style={ 621 item.color?.startsWith("#") 622 ? { 623 borderColor: item.color, 624 backgroundColor: `${item.color}15`, 625 } 626 : undefined 627 } 628 > 629 <a 630 href={`${pageUrl}#:~:text=${item.target.selector.prefix ? encodeURIComponent(item.target.selector.prefix) + "-," : ""}${encodeURIComponent(item.target.selector.exact)}${item.target.selector.suffix ? ",-" + encodeURIComponent(item.target.selector.suffix) : ""}`} 631 target="_blank" 632 rel="noopener noreferrer" 633 onClick={(e) => { 634 const sel = item.target?.selector; 635 if (!sel) return; 636 const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`; 637 handleExternalClick(e, url); 638 }} 639 className="block" 640 > 641 "{item.target?.selector?.exact}" 642 </a> 643 </blockquote> 644 )} 645 646 {item.body?.value && ( 647 <p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]"> 648 <RichText text={item.body.value} /> 649 </p> 650 )} 651 652 {item.tags && item.tags.length > 0 && ( 653 <div className="flex flex-wrap gap-2 mt-3"> 654 {item.tags.map((tag) => ( 655 <Link 656 key={tag} 657 to={`/home?tag=${encodeURIComponent(tag)}`} 658 className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-surface-100 dark:bg-surface-800 text-xs font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 659 onClick={(e) => e.stopPropagation()} 660 > 661 <Tag size={10} /> 662 <span>{tag}</span> 663 </Link> 664 ))} 665 </div> 666 )} 667 </div> 668 669 <div className="flex items-center gap-1 mt-3 ml-[52px] md:ml-0 md:gap-0"> 670 <button 671 onClick={handleLike} 672 className={clsx( 673 "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm transition-all", 674 liked 675 ? "text-red-500 bg-red-50 dark:bg-red-900/20" 676 : "text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20", 677 )} 678 > 679 <Heart size={16} className={clsx(liked && "fill-current")} /> 680 {likes > 0 && <span className="text-xs font-medium">{likes}</span>} 681 </button> 682 683 {type === "annotation" && ( 684 <Link 685 to={detailUrl} 686 className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all" 687 > 688 <MessageSquare size={16} /> 689 {(item.replyCount || 0) > 0 && ( 690 <span className="text-xs font-medium">{item.replyCount}</span> 691 )} 692 </Link> 693 )} 694 695 {user && ( 696 <button 697 onClick={() => setShowCollectionModal(true)} 698 className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all" 699 title="Add to Collection" 700 > 701 <FolderPlus size={16} /> 702 </button> 703 )} 704 705 {!hideShare && ( 706 <ShareMenu 707 uri={item.uri} 708 text={item.body?.value || ""} 709 handle={item.author?.handle} 710 type={type} 711 url={pageUrl} 712 /> 713 )} 714 715 {isAuthor && ( 716 <> 717 <div className="flex-1" /> 718 {type === "highlight" && !showConvertInput && ( 719 <button 720 onClick={() => setShowConvertInput(true)} 721 className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all text-xs font-medium" 722 title="Annotate this highlight" 723 > 724 <MessageSquare size={14} /> 725 <span className="hidden sm:inline">Annotate</span> 726 </button> 727 )} 728 <button 729 onClick={() => setShowEditModal(true)} 730 className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-all" 731 title="Edit" 732 > 733 <Edit3 size={14} /> 734 </button> 735 <button 736 onClick={handleDelete} 737 className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all" 738 title="Delete" 739 > 740 <Trash2 size={14} /> 741 </button> 742 </> 743 )} 744 745 {!isAuthor && user && ( 746 <> 747 <div className="flex-1" /> 748 <MoreMenu 749 items={(() => { 750 const menuItems: MoreMenuItem[] = [ 751 { 752 label: "Report", 753 icon: <Flag size={14} />, 754 onClick: () => setShowReportModal(true), 755 variant: "danger", 756 }, 757 { 758 label: `Mute @${item.author?.handle || "user"}`, 759 icon: <VolumeX size={14} />, 760 onClick: async () => { 761 if (item.author?.did) { 762 await muteUser(item.author.did); 763 onDelete?.(item.uri); 764 } 765 }, 766 }, 767 { 768 label: `Block @${item.author?.handle || "user"}`, 769 icon: <ShieldBan size={14} />, 770 onClick: async () => { 771 if (item.author?.did) { 772 await blockUser(item.author.did); 773 onDelete?.(item.uri); 774 } 775 }, 776 variant: "danger", 777 }, 778 ]; 779 return menuItems; 780 })()} 781 /> 782 </> 783 )} 784 </div> 785 786 {showConvertInput && ( 787 <div 788 className={clsx( 789 "mt-3 animate-fade-in", 790 layout === "mosaic" ? "" : "ml-[52px]", 791 )} 792 > 793 <div className="flex gap-2 items-end"> 794 <textarea 795 value={convertText} 796 onChange={(e) => setConvertText(e.target.value)} 797 placeholder="Add your note to convert this highlight into an annotation..." 798 autoFocus 799 onKeyDown={(e) => { 800 if (e.key === "Enter" && !e.shiftKey) { 801 e.preventDefault(); 802 handleConvert(); 803 } 804 if (e.key === "Escape") { 805 setShowConvertInput(false); 806 setConvertText(""); 807 } 808 }} 809 className="flex-1 p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-sm resize-none focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-400/20 min-h-[80px] placeholder:text-surface-400" 810 /> 811 <div className="flex flex-col gap-1.5"> 812 <button 813 onClick={handleConvert} 814 disabled={converting || !convertText.trim()} 815 className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-40 disabled:cursor-not-allowed transition-all" 816 title="Convert to annotation" 817 > 818 {converting ? ( 819 <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" /> 820 ) : ( 821 <Send size={16} /> 822 )} 823 </button> 824 <button 825 onClick={() => { 826 setShowConvertInput(false); 827 setConvertText(""); 828 }} 829 className="p-2.5 text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-all" 830 title="Cancel" 831 > 832 <X size={16} /> 833 </button> 834 </div> 835 </div> 836 </div> 837 )} 838 839 <AddToCollectionModal 840 isOpen={showCollectionModal} 841 onClose={() => setShowCollectionModal(false)} 842 annotationUri={item.uri} 843 /> 844 845 <ExternalLinkModal 846 isOpen={showExternalLinkModal} 847 onClose={() => setShowExternalLinkModal(false)} 848 url={externalLinkUrl} 849 /> 850 851 <ReportModal 852 isOpen={showReportModal} 853 onClose={() => setShowReportModal(false)} 854 subjectDid={item.author?.did || ""} 855 subjectUri={item.uri} 856 subjectHandle={item.author?.handle} 857 /> 858 859 <EditItemModal 860 isOpen={showEditModal} 861 onClose={() => setShowEditModal(false)} 862 item={item} 863 type={type} 864 onSaved={(updated) => { 865 setItem(updated); 866 onUpdate?.(updated); 867 }} 868 /> 869 <EditHistoryModal 870 isOpen={showEditHistory} 871 onClose={() => setShowEditHistory(false)} 872 item={item} 873 /> 874 </article> 875 ); 876}