(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947import React, { useState } from "react";import { formatDistanceToNow } from "date-fns";import { useTranslation } from "react-i18next";import RichText from "./RichText";import MoreMenu from "./MoreMenu";import type { MoreMenuItem } from "./MoreMenu";import { MessageSquare, Heart, ExternalLink, FolderPlus, Trash2, Edit3, Globe, ShieldBan, VolumeX, Flag, EyeOff, Eye, Tag, Send, X, Bookmark,} from "lucide-react";import ShareMenu from "../modals/ShareMenu";import AddToCollectionModal from "../modals/AddToCollectionModal";import ExternalLinkModal from "../modals/ExternalLinkModal";import ReportModal from "../modals/ReportModal";import EditItemModal from "../modals/EditItemModal";import EditHistoryModal from "../modals/EditHistoryModal";import { clsx } from "clsx";import { likeItem, unlikeItem, deleteItem, blockUser, muteUser, convertHighlightToAnnotation,} from "../../api/client";import { $user } from "../../store/auth";import { $preferences } from "../../store/preferences";import { useStore } from "@nanostores/react";import type { AnnotationItem, ContentLabel, LabelVisibility,} from "../../types";
import { Avatar } from "../ui";import CollectionIcon from "./CollectionIcon";import ProfileHoverCard from "./ProfileHoverCard";import { analytics } from "../../lib/analytics";
const LABEL_DESCRIPTIONS: Record<string, string> = { sexual: "Sexual Content", nudity: "Nudity", violence: "Violence", gore: "Graphic Content", spam: "Spam", misleading: "Misleading",};
function getContentWarning( labels?: ContentLabel[], prefs?: { labelPreferences: { labelerDid: string; label: string; visibility: LabelVisibility; }[]; },): { label: string; description: string; visibility: LabelVisibility; isAccountWide: boolean;} | null { if (!labels || labels.length === 0) return null; const priority = [ "gore", "violence", "nudity", "sexual", "misleading", "spam", ]; for (const p of priority) { const match = labels.find((l) => l.val === p); if (match) { const pref = prefs?.labelPreferences.find( (lp) => lp.label === p && lp.labelerDid === match.src, ); const visibility: LabelVisibility = pref?.visibility || "warn"; if (visibility === "ignore") continue; return { label: p, description: LABEL_DESCRIPTIONS[p] || p, visibility, isAccountWide: match.scope === "account", }; } } return null;}
interface CardProps { item: AnnotationItem; onDelete?: (uri: string) => void; onUpdate?: (item: AnnotationItem) => void; hideShare?: boolean; hideCollection?: boolean; layout?: "list" | "mosaic";}
export default function Card({ item: initialItem, onDelete, onUpdate, hideShare, hideCollection = false, layout = "list",}: CardProps) { const { t } = useTranslation(); const [item, setItem] = useState(initialItem); const user = useStore($user); const preferences = useStore($preferences); const isAuthor = user && item.author?.did === user.did;
const [liked, setLiked] = useState(!!item.viewer?.like); const [likes, setLikes] = useState(item.likeCount || 0); const [showCollectionModal, setShowCollectionModal] = useState(false); const [showExternalLinkModal, setShowExternalLinkModal] = useState(false); const [externalLinkUrl, setExternalLinkUrl] = useState<string | null>(null); const [showReportModal, setShowReportModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditHistory, setShowEditHistory] = useState(false); const [contentRevealed, setContentRevealed] = useState(false); const [showConvertInput, setShowConvertInput] = useState(false); const [convertText, setConvertText] = useState(""); const [converting, setConverting] = useState(false); const [ogData, setOgData] = useState<{ title?: string; description?: string; image?: string; icon?: string; } | null>(() => { if (initialItem.motivation !== "bookmarking") return null; const url = initialItem.target?.source || initialItem.source; if (!url) return null; try { const cached = sessionStorage.getItem(`og:${url}`); return cached ? JSON.parse(cached) : null; } catch { return null; } }); const [imgError, setImgError] = useState(false); const [iconError, setIconError] = useState(false);
const contentWarning = getContentWarning(item.labels, preferences);
React.useEffect(() => { setItem(initialItem); }, [initialItem]);
React.useEffect(() => { setLiked(!!item.viewer?.like); setLikes(item.likeCount || 0); }, [item.viewer?.like, item.likeCount]);
const type = item.motivation === "highlighting" ? "highlight" : item.motivation === "bookmarking" ? "bookmark" : "annotation";
const isSemble = item.uri?.includes("network.cosmik") || item.uri?.includes("semble");
const isLichen = item.uri?.includes("wiki.lichen.bookmark");
const isCommunityBookmark = item.uri?.includes( "community.lexicon.bookmarks.bookmark", );
const safeUrlHostname = (url: string | null | undefined) => { if (!url) return null; try { return new URL(url).hostname; } catch { return null; } };
const pageUrl = item.target?.source || item.source; const isBookmark = type === "bookmark" && !item.body?.value;
React.useEffect(() => { if (isBookmark && item.uri && !ogData && pageUrl) { let cancelled = false; import("../../lib/metadataQueue").then(({ fetchMetadata }) => { fetchMetadata(pageUrl).then((data) => { if (!cancelled && data) setOgData(data); }); }); return () => { cancelled = true; }; } }, [isBookmark, item.uri, pageUrl, ogData]);
if (contentWarning?.visibility === "hide") return null;
const handleLike = async () => { const prev = { liked, likes }; setLiked(!liked); setLikes((l) => (liked ? l - 1 : l + 1));
const success = liked ? await unlikeItem(item.uri) : await likeItem(item.uri, item.cid);
if (!success) { setLiked(prev.liked); setLikes(prev.likes); } else { analytics.capture("item_liked", { type, action: liked ? "unlike" : "like", }); } };
const handleDelete = async () => { if (window.confirm(t("card.deleteConfirm"))) { const success = await deleteItem(item.uri, type); if (success && onDelete) { analytics.capture("item_deleted", { type }); onDelete(item.uri); } } };
const handleConvert = async () => { if (!convertText.trim() || converting) return; setConverting(true); const pageUrl = item.target?.source || item.source || ""; const res = await convertHighlightToAnnotation( item.uri, pageUrl, convertText.trim(), item.target?.selector, item.target?.title, ); setConverting(false); if (res.success) { setShowConvertInput(false); setConvertText(""); if (onDelete) onDelete(item.uri); } };
const handleExternalClick = (e: React.MouseEvent, url: string) => { e.preventDefault(); e.stopPropagation();
try { const hostname = safeUrlHostname(url); if (hostname) { if ( hostname === "margin.at" || hostname.endsWith(".margin.at") || hostname === "semble.so" || hostname.endsWith(".semble.so") ) { window.open(url, "_blank", "noopener,noreferrer"); return; }
if ($preferences.get().disableExternalLinkWarning) { window.open(url, "_blank", "noopener,noreferrer"); return; }
const skipped = $preferences.get().externalLinkSkippedHostnames; if (skipped.includes(hostname)) { window.open(url, "_blank", "noopener,noreferrer"); return; } } } catch (err) { if (err instanceof Error && err.name !== "TypeError") { console.debug("Failed to check skipped hostname:", err); } }
setExternalLinkUrl(url); setShowExternalLinkModal(true); };
const timestamp = item.createdAt ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false }) .replace("less than a minute", t("card.justNow")) .replace("about ", "") .replace(" hours", "h") .replace(" hour", "h") .replace(" minutes", "m") .replace(" minute", "m") .replace(" days", "d") .replace(" day", "d") : "";
const uriCollection = item.uri?.split("/")[3] ?? ""; const urlSegment = uriCollection.includes("at.margin.note") ? "note" : uriCollection.includes("at.margin.highlight") ? "highlight" : uriCollection.includes("at.margin.bookmark") ? "bookmark" : uriCollection.includes("at.margin.annotation") ? "annotation" : type; const detailUrl = `/${item.author?.handle || item.author?.did}/${urlSegment}/${(item.uri || "").split("/").pop()}`;
const pageTitle = item.target?.title || item.title || (pageUrl ? safeUrlHostname(pageUrl) : null); const displayUrl = pageUrl ? (() => { const clean = pageUrl .replace(/^https?:\/\//, "") .replace(/^www\./, "") .replace(/\/$/, ""); return clean.length > 60 ? clean.slice(0, 57) + "..." : clean; })() : null;
const decodeHTMLEntities = (text: string) => { if (!text.includes("&")) return text; try { const doc = new DOMParser().parseFromString( `<!doctype html><body>${text}`, "text/html", ); return doc.body.textContent ?? text; } catch { return text; } };
const displayTitle = decodeHTMLEntities( item.title || ogData?.title || pageTitle || t("card.untitledBookmark"), ); const displayDescription = item.description || ogData?.description ? decodeHTMLEntities(item.description || ogData?.description || "") : undefined; const displayImage = ogData?.image;
return ( <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative overflow-visible"> {!hideCollection && (item.collection || (item.context && item.context.length > 0)) && ( <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap"> {item.addedBy && item.addedBy.did !== item.author?.did ? ( <> <ProfileHoverCard did={item.addedBy.did}> <a href={`/profile/${item.addedBy.did}`} className="flex items-center gap-1.5 font-medium hover:text-primary-600 dark:hover:text-primary-400 transition-colors" > <Avatar did={item.addedBy.did} avatar={item.addedBy.avatar} size="xs" /> <span> {item.addedBy.displayName || `@${item.addedBy.handle}`} </span> </a> </ProfileHoverCard> <span>{t("card.addedToLower")}</span> </> ) : ( <span>{t("card.addedTo")}</span> )}
{item.context && item.context.length > 0 ? ( item.context.map((col, index) => ( <React.Fragment key={col.uri}> {index > 0 && index < item.context!.length - 1 && ( <span className="text-surface-300 dark:text-surface-600"> , </span> )} {index > 0 && index === item.context!.length - 1 && ( <span>{t("card.and")}</span> )} <a href={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`} className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" > <CollectionIcon icon={col.icon} size={14} /> <span className="font-medium">{col.name}</span> </a> </React.Fragment> )) ) : ( <a href={`/${item.addedBy?.handle || ""}/collection/${(item.collection!.uri || "").split("/").pop()}`} className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" > <CollectionIcon icon={item.collection!.icon} size={14} /> <span className="font-medium">{item.collection!.name}</span> </a> )} </div> )}
<div className="flex items-start gap-3"> <ProfileHoverCard did={item.author?.did}> <a href={`/profile/${item.author?.did}`} className="shrink-0"> <div className="rounded-full overflow-hidden"> <div className={clsx( "transition-all", contentWarning?.isAccountWide && !contentRevealed && "blur-md", )} > <Avatar did={item.author?.did} avatar={item.author?.avatar} size="md" /> </div> </div> </a> </ProfileHoverCard>
<div className="flex-1 min-w-0"> <div className="flex items-center gap-1.5 flex-wrap"> <ProfileHoverCard did={item.author?.did}> <a href={`/profile/${item.author?.did}`} className="font-semibold text-surface-900 dark:text-white text-[15px] hover:underline" > {item.author?.displayName || item.author?.handle} </a> </ProfileHoverCard> <span className="text-surface-400 dark:text-surface-500 text-sm"> @{item.author?.handle} </span> <span className="text-surface-300 dark:text-surface-600">·</span> <span className="text-surface-400 dark:text-surface-500 text-sm"> {timestamp} {item.editedAt && ( <button onClick={(e) => { e.preventDefault(); e.stopPropagation(); setShowEditHistory(true); }} className="ml-1 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-400 hover:underline cursor-pointer" title={`Edited ${new Date(item.editedAt).toLocaleString()}`} > {t("card.edited")} </button> )} </span>
{isSemble && (() => { const uri = item.uri || ""; const parts = uri.replace("at://", "").split("/"); const userHandle = item.author?.handle || parts[0] || ""; const rkey = parts[2] || ""; const targetUrl = item.target?.source || item.source || ""; let sembleUrl = `https://semble.so/profile/${userHandle}`; if (uri.includes("network.cosmik.collection")) sembleUrl = `https://semble.so/profile/${userHandle}/collections/${rkey}`; else if (uri.includes("network.cosmik.card") && targetUrl) sembleUrl = `https://semble.so/url?id=${encodeURIComponent(targetUrl)}`; return ( <span className="relative inline-flex items-center"> <span className="text-surface-300 dark:text-surface-600"> · </span> <button onClick={(e) => handleExternalClick(e, sembleUrl)} className="group/semble relative inline-flex items-center ml-1 cursor-pointer" > <img src="/semble-logo.svg" alt="Semble" className="h-3.5" /> <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"> {t("card.openInSemble")} <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" /> </span> </button> </span> ); })()}
{isLichen && ( <span className="relative inline-flex items-center"> <span className="text-surface-300 dark:text-surface-600"> · </span> <span className="group/lichen relative inline-flex items-center ml-1"> <img src="/lichen-logo.svg" alt="Lichen" className="h-3.5" /> <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"> {t("card.lichenBookmark")} <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" /> </span> </span> </span> )}
{isCommunityBookmark && ( <span className="relative inline-flex items-center"> <span className="text-surface-300 dark:text-surface-600"> · </span> <span className="group/cb relative inline-flex items-center ml-1"> <Bookmark size={12} className="text-surface-400 dark:text-surface-500 fill-current" /> <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"> {t("card.communityBookmark")} <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" /> </span> </span> </span> )} </div>
{pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && ( <a href={pageUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => handleExternalClick(e, pageUrl)} className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5 max-w-full" > <ExternalLink size={10} className="flex-shrink-0" /> <span className="truncate">{displayUrl}</span> </a> )} </div> </div>
<div className={clsx( "mt-3 relative", layout === "mosaic" ? "" : "ml-[52px]", )} > {contentWarning && !contentRevealed && ( <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]"> <div className="flex items-center gap-2 text-surface-500 dark:text-surface-400"> <EyeOff size={16} /> <span className="text-sm font-medium"> {t(`card.labelDescriptions.${contentWarning.label}`, { defaultValue: contentWarning.description, })} </span> </div> <button onClick={() => setContentRevealed(true)} 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" > <Eye size={12} /> {t("card.show")} </button> </div> )} {contentWarning && contentRevealed && ( <button onClick={() => setContentRevealed(false)} 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" > <EyeOff size={12} /> {t("card.hideContent")} </button> )} {!(contentWarning && !contentRevealed) && isBookmark && ( <div onClick={(e) => { e.preventDefault(); if (pageUrl) handleExternalClick(e, pageUrl); }} role="button" tabIndex={0} className={clsx( "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", layout === "mosaic" ? "flex-col items-stretch" : "flex-row items-stretch", )} > {displayImage && !imgError && ( <div className={clsx( "shrink-0 bg-surface-200 dark:bg-surface-700 relative", layout === "mosaic" ? "w-full aspect-video border-b border-surface-200 dark:border-surface-700" : "w-[140px] sm:w-[180px] border-r border-surface-200 dark:border-surface-700", )} > <div className="absolute inset-0 flex items-center justify-center overflow-hidden"> <img src={displayImage} alt={displayTitle || "Link preview"} className="h-full w-full object-cover" onError={() => setImgError(true)} /> </div> </div> )} <div className={clsx( "p-3 min-w-0 flex flex-col font-sans", layout === "mosaic" ? "w-full" : "flex-1 justify-center", )} > <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"> {displayTitle} </h3>
{displayDescription && ( <p className="text-surface-600 dark:text-surface-400 text-xs leading-relaxed mb-2 line-clamp-2"> {displayDescription} </p> )}
<div className="flex items-center gap-2 text-[11px] text-surface-500 dark:text-surface-500 mt-auto"> <div className="w-4 h-4 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden"> {ogData?.icon && !iconError ? ( <img src={ogData.icon} alt="" onError={() => setIconError(true)} className="w-3 h-3 object-contain" /> ) : ( <Globe size={9} /> )} </div> <span className="truncate max-w-[200px]"> {displayUrl || pageUrl} </span> </div> </div> </div> )}
{!(contentWarning && !contentRevealed) && item.target?.selector?.exact && ( <blockquote className={clsx( "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", !item.color && type === "highlight" && "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20", item.color === "yellow" && "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20", item.color === "green" && "border-green-400 bg-green-50/50 dark:bg-green-900/20", item.color === "red" && "border-red-400 bg-red-50/50 dark:bg-red-900/20", item.color === "blue" && "border-blue-400 bg-blue-50/50 dark:bg-blue-900/20", !item.color && type !== "highlight" && "border-surface-300 dark:border-surface-600", )} style={ item.color?.startsWith("#") ? { borderColor: item.color, backgroundColor: `${item.color}15`, } : undefined } > <a 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) : ""}`} target="_blank" rel="noopener noreferrer" onClick={(e) => { const sel = item.target?.selector; if (!sel) return; const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`; handleExternalClick(e, url); }} className="block break-words" > "{item.target?.selector?.exact}" </a> </blockquote> )}
{!(contentWarning && !contentRevealed) && item.body?.value && ( <p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap break-words leading-relaxed text-[15px]"> <RichText text={item.body.value} /> </p> )}
{!(contentWarning && !contentRevealed) && item.tags && item.tags.length > 0 && ( <div className="flex flex-wrap gap-2 mt-3"> {item.tags.map((tag) => ( <a key={tag} href={`/home?tag=${encodeURIComponent(tag)}`} 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" onClick={(e) => e.stopPropagation()} > <Tag size={10} /> <span>{tag}</span> </a> ))} </div> )} </div>
<div className="flex items-center gap-1 mt-3 ml-[52px] md:ml-0 md:gap-0"> <button onClick={handleLike} className={clsx( "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm transition-all", liked ? "text-red-500 bg-red-50 dark:bg-red-900/20" : "text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20", )} > <Heart size={16} className={clsx(liked && "fill-current")} /> {likes > 0 && <span className="text-xs font-medium">{likes}</span>} </button>
{type === "annotation" && ( <a href={detailUrl} 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" > <MessageSquare size={16} /> {(item.replyCount || 0) > 0 && ( <span className="text-xs font-medium">{item.replyCount}</span> )} </a> )}
{user && ( <button onClick={() => setShowCollectionModal(true)} 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" title={t("card.addToCollectionTitle")} > <FolderPlus size={16} /> </button> )}
{!hideShare && ( <ShareMenu uri={item.uri} text={item.body?.value || ""} handle={item.author?.handle} type={type} url={pageUrl} /> )}
{isAuthor && ( <> <div className="flex-1" /> {type === "highlight" && !showConvertInput && ( <button onClick={() => setShowConvertInput(true)} 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" title={t("card.annotateTitle")} > <MessageSquare size={14} /> <span className="hidden sm:inline">{t("card.annotate")}</span> </button> )} <button onClick={() => setShowEditModal(true)} 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" title={t("card.editTitle")} > <Edit3 size={14} /> </button> <button onClick={handleDelete} 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" title={t("card.deleteTitle")} > <Trash2 size={14} /> </button> </> )}
{!isAuthor && user && ( <> <div className="flex-1" /> <MoreMenu items={(() => { const menuItems: MoreMenuItem[] = [ { label: t("card.report"), icon: <Flag size={14} />, onClick: () => setShowReportModal(true), variant: "danger", }, { label: t("card.muteUser", { handle: item.author?.handle || "user", }), icon: <VolumeX size={14} />, onClick: async () => { if (item.author?.did) { await muteUser(item.author.did); onDelete?.(item.uri); } }, }, { label: t("card.blockUser", { handle: item.author?.handle || "user", }), icon: <ShieldBan size={14} />, onClick: async () => { if (item.author?.did) { await blockUser(item.author.did); onDelete?.(item.uri); } }, variant: "danger", }, ]; return menuItems; })()} /> </> )} </div>
{showConvertInput && ( <div className={clsx( "mt-3 animate-fade-in", layout === "mosaic" ? "" : "ml-[52px]", )} > <div className="flex gap-2 items-end"> <textarea value={convertText} onChange={(e) => setConvertText(e.target.value)} placeholder={t("card.addNotePlaceholder")} autoFocus onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleConvert(); } if (e.key === "Escape") { setShowConvertInput(false); setConvertText(""); } }} 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" /> <div className="flex flex-col gap-1.5"> <button onClick={handleConvert} disabled={converting || !convertText.trim()} className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-40 disabled:cursor-not-allowed transition-all" title={t("card.convertToAnnotation")} > {converting ? ( <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" /> ) : ( <Send size={16} /> )} </button> <button onClick={() => { setShowConvertInput(false); setConvertText(""); }} 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" title={t("common.cancel")} > <X size={16} /> </button> </div> </div> </div> )}
<AddToCollectionModal isOpen={showCollectionModal} onClose={() => setShowCollectionModal(false)} annotationUri={item.uri} />
<ExternalLinkModal isOpen={showExternalLinkModal} onClose={() => setShowExternalLinkModal(false)} url={externalLinkUrl} />
<ReportModal isOpen={showReportModal} onClose={() => setShowReportModal(false)} subjectDid={item.author?.did || ""} subjectUri={item.uri} subjectHandle={item.author?.handle} />
<EditItemModal isOpen={showEditModal} onClose={() => setShowEditModal(false)} item={item} type={type} onSaved={(updated) => { setItem(updated); onUpdate?.(updated); }} /> <EditHistoryModal isOpen={showEditHistory} onClose={() => setShowEditHistory(false)} item={item} /> </article> );}