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