Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}