1import { useState, useEffect } from "react";
2import { useAuth } from "../context/AuthContext";
3import ReplyList from "./ReplyList";
4import { Link } from "react-router-dom";
5import {
6 normalizeAnnotation,
7 normalizeHighlight,
8 deleteAnnotation,
9 likeAnnotation,
10 unlikeAnnotation,
11 getReplies,
12 createReply,
13 deleteReply,
14 getLikeCount,
15 updateAnnotation,
16 updateHighlight,
17 updateBookmark,
18 getEditHistory,
19} from "../api/client";
20import {
21 HeartIcon,
22 MessageIcon,
23 TrashIcon,
24 ExternalLinkIcon,
25 HighlightIcon,
26 BookmarkIcon,
27} from "./Icons";
28import { Folder, Edit2, Save, X, Clock } from "lucide-react";
29import AddToCollectionModal from "./AddToCollectionModal";
30import ShareMenu from "./ShareMenu";
31
32function buildTextFragmentUrl(baseUrl, selector) {
33 if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) {
34 return baseUrl;
35 }
36
37 let fragment = ":~:text=";
38 if (selector.prefix) {
39 fragment += encodeURIComponent(selector.prefix) + "-,";
40 }
41 fragment += encodeURIComponent(selector.exact);
42 if (selector.suffix) {
43 fragment += ",-" + encodeURIComponent(selector.suffix);
44 }
45
46 return baseUrl + "#" + fragment;
47}
48
49const truncateUrl = (url, maxLength = 60) => {
50 if (!url) return "";
51 try {
52 const parsed = new URL(url);
53 const fullPath = parsed.host + parsed.pathname;
54 if (fullPath.length > maxLength)
55 return fullPath.substring(0, maxLength) + "...";
56 return fullPath;
57 } catch {
58 return url.length > maxLength ? url.substring(0, maxLength) + "..." : url;
59 }
60};
61
62export default function AnnotationCard({ annotation, onDelete }) {
63 const { user, login } = useAuth();
64 const data = normalizeAnnotation(annotation);
65
66 const [likeCount, setLikeCount] = useState(0);
67 const [isLiked, setIsLiked] = useState(false);
68 const [deleting, setDeleting] = useState(false);
69 const [showAddToCollection, setShowAddToCollection] = useState(false);
70 const [isEditing, setIsEditing] = useState(false);
71 const [editText, setEditText] = useState(data.text || "");
72 const [saving, setSaving] = useState(false);
73
74 const [showHistory, setShowHistory] = useState(false);
75 const [editHistory, setEditHistory] = useState([]);
76 const [loadingHistory, setLoadingHistory] = useState(false);
77
78 const [replies, setReplies] = useState([]);
79 const [replyCount, setReplyCount] = useState(0);
80 const [showReplies, setShowReplies] = useState(false);
81 const [replyingTo, setReplyingTo] = useState(null);
82 const [replyText, setReplyText] = useState("");
83 const [posting, setPosting] = useState(false);
84
85 const isOwner = user?.did && data.author?.did === user.did;
86
87 const [hasEditHistory, setHasEditHistory] = useState(false);
88
89 useEffect(() => {
90 let mounted = true;
91 async function fetchData() {
92 try {
93 const repliesRes = await getReplies(data.uri);
94 if (mounted && repliesRes.items) {
95 setReplies(repliesRes.items);
96 setReplyCount(repliesRes.items.length);
97 }
98
99 const likeRes = await getLikeCount(data.uri);
100 if (mounted) {
101 if (likeRes.count !== undefined) {
102 setLikeCount(likeRes.count);
103 }
104 if (likeRes.liked !== undefined) {
105 setIsLiked(likeRes.liked);
106 }
107 }
108
109 if (!data.color && !data.description) {
110 try {
111 const history = await getEditHistory(data.uri);
112 if (mounted && history && history.length > 0) {
113 setHasEditHistory(true);
114 }
115 } catch {}
116 }
117 } catch (err) {
118 console.error("Failed to fetch data:", err);
119 }
120 }
121 if (data.uri) {
122 fetchData();
123 }
124 return () => {
125 mounted = false;
126 };
127 }, [data.uri]);
128
129 const fetchHistory = async () => {
130 if (showHistory) {
131 setShowHistory(false);
132 return;
133 }
134 try {
135 setLoadingHistory(true);
136 setShowHistory(true);
137 const history = await getEditHistory(data.uri);
138 setEditHistory(history);
139 } catch (err) {
140 console.error("Failed to fetch history:", err);
141 } finally {
142 setLoadingHistory(false);
143 }
144 };
145
146 const handlePostReply = async (parentReply) => {
147 if (!replyText.trim()) return;
148
149 try {
150 setPosting(true);
151 const parentUri = parentReply
152 ? parentReply.id || parentReply.uri
153 : data.uri;
154 const parentCid = parentReply
155 ? parentReply.cid
156 : annotation.cid || data.cid;
157
158 await createReply({
159 parentUri,
160 parentCid: parentCid || "",
161 rootUri: data.uri,
162 rootCid: annotation.cid || data.cid || "",
163 text: replyText,
164 });
165
166 setReplyText("");
167 setReplyingTo(null);
168
169 const res = await getReplies(data.uri);
170 if (res.items) {
171 setReplies(res.items);
172 setReplyCount(res.items.length);
173 }
174 } catch (err) {
175 alert("Failed to post reply: " + err.message);
176 } finally {
177 setPosting(false);
178 }
179 };
180
181 const handleSaveEdit = async () => {
182 try {
183 setSaving(true);
184 await updateAnnotation(data.uri, editText, data.tags);
185 setIsEditing(false);
186 if (annotation.body) annotation.body.value = editText;
187 else if (annotation.text) annotation.text = editText;
188 } catch (err) {
189 alert("Failed to update: " + err.message);
190 } finally {
191 setSaving(false);
192 }
193 };
194
195 const formatDate = (dateString, simple = true) => {
196 if (!dateString) return "";
197 const date = new Date(dateString);
198 const now = new Date();
199 const diff = now - date;
200 const minutes = Math.floor(diff / 60000);
201 const hours = Math.floor(diff / 3600000);
202 const days = Math.floor(diff / 86400000);
203 if (minutes < 1) return "just now";
204 if (minutes < 60) return `${minutes}m`;
205 if (hours < 24) return `${hours}h`;
206 if (days < 7) return `${days}d`;
207 if (simple)
208 return date.toLocaleDateString("en-US", {
209 month: "short",
210 day: "numeric",
211 });
212 return date.toLocaleString();
213 };
214
215 const authorDisplayName = data.author?.displayName || data.author?.handle;
216 const authorHandle = data.author?.handle;
217 const authorAvatar = data.author?.avatar;
218 const authorDid = data.author?.did;
219 const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null;
220 const highlightedText =
221 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
222 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
223
224 const handleLike = async () => {
225 if (!user) {
226 login();
227 return;
228 }
229 try {
230 if (isLiked) {
231 setIsLiked(false);
232 setLikeCount((prev) => Math.max(0, prev - 1));
233 await unlikeAnnotation(data.uri);
234 } else {
235 setIsLiked(true);
236 setLikeCount((prev) => prev + 1);
237 const cid = annotation.cid || data.cid || "";
238 if (data.uri && cid) await likeAnnotation(data.uri, cid);
239 }
240 } catch (err) {
241 setIsLiked(!isLiked);
242 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1));
243 console.error("Failed to toggle like:", err);
244 }
245 };
246
247 const handleShare = async () => {
248 const uriParts = data.uri.split("/");
249 const did = uriParts[2];
250 const rkey = uriParts[uriParts.length - 1];
251 const shareUrl = `${window.location.origin}/at/${did}/${rkey}`;
252
253 if (navigator.share) {
254 try {
255 await navigator.share({
256 title: "Margin Annotation",
257 text: data.text?.substring(0, 100),
258 url: shareUrl,
259 });
260 } catch (err) {}
261 } else {
262 try {
263 await navigator.clipboard.writeText(shareUrl);
264 alert("Link copied!");
265 } catch {
266 prompt("Copy this link:", shareUrl);
267 }
268 }
269 };
270
271 const handleDelete = async () => {
272 if (!confirm("Delete this annotation? This cannot be undone.")) return;
273 try {
274 setDeleting(true);
275 const parts = data.uri.split("/");
276 const rkey = parts[parts.length - 1];
277 await deleteAnnotation(rkey);
278 if (onDelete) onDelete(data.uri);
279 else window.location.reload();
280 } catch (err) {
281 alert("Failed to delete: " + err.message);
282 } finally {
283 setDeleting(false);
284 }
285 };
286
287 return (
288 <article className="card annotation-card">
289 <header className="annotation-header">
290 <Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
291 <div className="annotation-avatar">
292 {authorAvatar ? (
293 <img src={authorAvatar} alt={authorDisplayName} />
294 ) : (
295 <span>
296 {(authorDisplayName || authorHandle || "??")
297 ?.substring(0, 2)
298 .toUpperCase()}
299 </span>
300 )}
301 </div>
302 </Link>
303 <div className="annotation-meta">
304 <div className="annotation-author-row">
305 <Link
306 to={marginProfileUrl || "#"}
307 className="annotation-author-link"
308 >
309 <span className="annotation-author">{authorDisplayName}</span>
310 </Link>
311 {authorHandle && (
312 <a
313 href={`https://bsky.app/profile/${authorHandle}`}
314 target="_blank"
315 rel="noopener noreferrer"
316 className="annotation-handle"
317 >
318 @{authorHandle} <ExternalLinkIcon size={12} />
319 </a>
320 )}
321 </div>
322 <div className="annotation-time">{formatDate(data.createdAt)}</div>
323 </div>
324 <div className="action-buttons">
325 {}
326 {hasEditHistory && !data.color && !data.description && (
327 <button
328 className="annotation-edit-btn"
329 onClick={fetchHistory}
330 title="View Edit History"
331 >
332 <Clock size={16} />
333 </button>
334 )}
335 {}
336 {isOwner && (
337 <>
338 {!data.color && !data.description && (
339 <button
340 className="annotation-edit-btn"
341 onClick={() => setIsEditing(!isEditing)}
342 title="Edit"
343 >
344 <Edit2 size={16} />
345 </button>
346 )}
347 <button
348 className="annotation-delete"
349 onClick={handleDelete}
350 disabled={deleting}
351 title="Delete"
352 >
353 <TrashIcon size={16} />
354 </button>
355 </>
356 )}
357 </div>
358 </header>
359
360 {}
361 {}
362 {showHistory && (
363 <div className="history-panel">
364 <div className="history-header">
365 <h4 className="history-title">Edit History</h4>
366 <button
367 className="history-close-btn"
368 onClick={() => setShowHistory(false)}
369 title="Close History"
370 >
371 <X size={14} />
372 </button>
373 </div>
374 {loadingHistory ? (
375 <div className="history-status">Loading history...</div>
376 ) : editHistory.length === 0 ? (
377 <div className="history-status">No edit history found.</div>
378 ) : (
379 <ul className="history-list">
380 {editHistory.map((edit) => (
381 <li key={edit.id} className="history-item">
382 <div className="history-date">
383 {new Date(edit.editedAt).toLocaleString()}
384 </div>
385 <div className="history-content">{edit.previousContent}</div>
386 </li>
387 ))}
388 </ul>
389 )}
390 </div>
391 )}
392
393 <a
394 href={data.url}
395 target="_blank"
396 rel="noopener noreferrer"
397 className="annotation-source"
398 >
399 {truncateUrl(data.url)}
400 {data.title && (
401 <span className="annotation-source-title"> • {data.title}</span>
402 )}
403 </a>
404
405 {highlightedText && (
406 <a
407 href={fragmentUrl}
408 target="_blank"
409 rel="noopener noreferrer"
410 className="annotation-highlight"
411 >
412 <mark>"{highlightedText}"</mark>
413 </a>
414 )}
415
416 {isEditing ? (
417 <div className="mt-3">
418 <textarea
419 value={editText}
420 onChange={(e) => setEditText(e.target.value)}
421 className="reply-input"
422 rows={3}
423 style={{ marginBottom: "8px" }}
424 />
425 <div className="action-buttons-end">
426 <button
427 onClick={() => setIsEditing(false)}
428 className="btn btn-ghost"
429 >
430 Cancel
431 </button>
432 <button
433 onClick={handleSaveEdit}
434 disabled={saving}
435 className="btn btn-primary btn-sm"
436 >
437 {saving ? (
438 "Saving..."
439 ) : (
440 <>
441 <Save size={14} /> Save
442 </>
443 )}
444 </button>
445 </div>
446 </div>
447 ) : (
448 data.text && <p className="annotation-text">{data.text}</p>
449 )}
450
451 {data.tags?.length > 0 && (
452 <div className="annotation-tags">
453 {data.tags.map((tag, i) => (
454 <span key={i} className="annotation-tag">
455 #{tag}
456 </span>
457 ))}
458 </div>
459 )}
460
461 <footer className="annotation-actions">
462 <button
463 className={`annotation-action ${isLiked ? "liked" : ""}`}
464 onClick={handleLike}
465 >
466 <HeartIcon filled={isLiked} size={16} />
467 {likeCount > 0 && <span>{likeCount}</span>}
468 </button>
469 <button
470 className={`annotation-action ${showReplies ? "active" : ""}`}
471 onClick={() => setShowReplies(!showReplies)}
472 >
473 <MessageIcon size={16} />
474 <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
475 </button>
476 <ShareMenu uri={data.uri} text={data.text} />
477 <button
478 className="annotation-action"
479 onClick={() => {
480 if (!user) {
481 login();
482 return;
483 }
484 setShowAddToCollection(true);
485 }}
486 >
487 <Folder size={16} />
488 <span>Collect</span>
489 </button>
490 </footer>
491
492 {showReplies && (
493 <div className="inline-replies">
494 <ReplyList
495 replies={replies}
496 rootUri={data.uri}
497 user={user}
498 onReply={(reply) => setReplyingTo(reply)}
499 onDelete={async (reply) => {
500 if (!confirm("Delete this reply?")) return;
501 try {
502 await deleteReply(reply.id || reply.uri);
503 const res = await getReplies(data.uri);
504 if (res.items) {
505 setReplies(res.items);
506 setReplyCount(res.items.length);
507 }
508 } catch (err) {
509 alert("Failed to delete: " + err.message);
510 }
511 }}
512 isInline={true}
513 />
514
515 <div className="reply-form">
516 {replyingTo && (
517 <div
518 style={{
519 display: "flex",
520 alignItems: "center",
521 gap: "8px",
522 marginBottom: "8px",
523 fontSize: "0.85rem",
524 color: "var(--text-secondary)",
525 }}
526 >
527 <span>
528 Replying to @
529 {(replyingTo.creator || replyingTo.author)?.handle ||
530 "unknown"}
531 </span>
532 <button
533 onClick={() => setReplyingTo(null)}
534 style={{
535 background: "none",
536 border: "none",
537 color: "var(--text-tertiary)",
538 cursor: "pointer",
539 padding: "2px 6px",
540 }}
541 >
542 ×
543 </button>
544 </div>
545 )}
546 <textarea
547 className="reply-input"
548 placeholder={
549 replyingTo
550 ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...`
551 : "Write a reply..."
552 }
553 value={replyText}
554 onChange={(e) => setReplyText(e.target.value)}
555 onFocus={(e) => {
556 if (!user) {
557 e.target.blur();
558 login();
559 }
560 }}
561 rows={2}
562 />
563 <div className="action-buttons-end">
564 <button
565 className="btn btn-primary btn-sm"
566 disabled={posting || !replyText.trim()}
567 onClick={() => {
568 if (!user) {
569 login();
570 return;
571 }
572 handlePostReply(replyingTo);
573 }}
574 >
575 {posting ? "Posting..." : "Reply"}
576 </button>
577 </div>
578 </div>
579 </div>
580 )}
581
582 <AddToCollectionModal
583 isOpen={showAddToCollection}
584 onClose={() => setShowAddToCollection(false)}
585 annotationUri={data.uri}
586 />
587 </article>
588 );
589}
590
591export function HighlightCard({ highlight, onDelete }) {
592 const { user, login } = useAuth();
593 const data = normalizeHighlight(highlight);
594 const highlightedText =
595 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
596 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
597 const isOwner = user?.did && data.author?.did === user.did;
598 const [showAddToCollection, setShowAddToCollection] = useState(false);
599 const [isEditing, setIsEditing] = useState(false);
600 const [editColor, setEditColor] = useState(data.color || "#f59e0b");
601
602 const handleSaveEdit = async () => {
603 try {
604 await updateHighlight(data.uri, editColor, []);
605 setIsEditing(false);
606
607 if (highlight.color) highlight.color = editColor;
608 } catch (err) {
609 alert("Failed to update: " + err.message);
610 }
611 };
612
613 const formatDate = (dateString, simple = true) => {
614 if (!dateString) return "";
615 const date = new Date(dateString);
616 const now = new Date();
617 const diff = now - date;
618 const minutes = Math.floor(diff / 60000);
619 const hours = Math.floor(diff / 3600000);
620 const days = Math.floor(diff / 86400000);
621 if (minutes < 1) return "just now";
622 if (minutes < 60) return `${minutes}m`;
623 if (hours < 24) return `${hours}h`;
624 if (days < 7) return `${days}d`;
625 if (simple)
626 return date.toLocaleDateString("en-US", {
627 month: "short",
628 day: "numeric",
629 });
630 return date.toLocaleString();
631 };
632
633 return (
634 <article className="card annotation-card">
635 <header className="annotation-header">
636 <Link
637 to={data.author?.did ? `/profile/${data.author.did}` : "#"}
638 className="annotation-avatar-link"
639 >
640 <div className="annotation-avatar">
641 {data.author?.avatar ? (
642 <img src={data.author.avatar} alt="avatar" />
643 ) : (
644 <span>??</span>
645 )}
646 </div>
647 </Link>
648 <div className="annotation-meta">
649 <Link to="#" className="annotation-author-link">
650 <span className="annotation-author">
651 {data.author?.displayName || "Unknown"}
652 </span>
653 </Link>
654 <div className="annotation-time">{formatDate(data.createdAt)}</div>
655 </div>
656 <div className="action-buttons">
657 {isOwner && (
658 <>
659 <button
660 className="annotation-edit-btn"
661 onClick={() => setIsEditing(!isEditing)}
662 title="Edit Color"
663 >
664 <Edit2 size={16} />
665 </button>
666 <button
667 className="annotation-delete"
668 onClick={(e) => {
669 e.preventDefault();
670 onDelete && onDelete(highlight.id || highlight.uri);
671 }}
672 >
673 <TrashIcon size={16} />
674 </button>
675 </>
676 )}
677 </div>
678 </header>
679
680 <a
681 href={data.url}
682 target="_blank"
683 rel="noopener noreferrer"
684 className="annotation-source"
685 >
686 {truncateUrl(data.url)}
687 </a>
688
689 {highlightedText && (
690 <a
691 href={fragmentUrl}
692 target="_blank"
693 rel="noopener noreferrer"
694 className="annotation-highlight"
695 style={{
696 borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
697 }}
698 >
699 <mark>"{highlightedText}"</mark>
700 </a>
701 )}
702
703 {isEditing && (
704 <div
705 className="mt-3"
706 style={{ display: "flex", alignItems: "center", gap: "8px" }}
707 >
708 <span style={{ fontSize: "0.9rem" }}>Color:</span>
709 <input
710 type="color"
711 value={editColor}
712 onChange={(e) => setEditColor(e.target.value)}
713 style={{
714 height: "32px",
715 width: "64px",
716 padding: 0,
717 border: "none",
718 borderRadius: "var(--radius-sm)",
719 overflow: "hidden",
720 }}
721 />
722 <button
723 onClick={handleSaveEdit}
724 className="btn btn-primary btn-sm"
725 style={{ marginLeft: "auto" }}
726 >
727 Save
728 </button>
729 </div>
730 )}
731
732 <footer className="annotation-actions">
733 <span
734 className="annotation-action annotation-type-badge"
735 style={{ color: data.color || "#f59e0b" }}
736 >
737 <HighlightIcon size={14} /> Highlight
738 </span>
739 <button
740 className="annotation-action"
741 onClick={() => {
742 if (!user) {
743 login();
744 return;
745 }
746 setShowAddToCollection(true);
747 }}
748 >
749 <Folder size={16} />
750 <span>Collect</span>
751 </button>
752 </footer>
753 <AddToCollectionModal
754 isOpen={showAddToCollection}
755 onClose={() => setShowAddToCollection(false)}
756 annotationUri={data.uri}
757 />
758 </article>
759 );
760}