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