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