(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}