forked from
margin.at/margin
Write on the margins of the internet. Powered by the AT Protocol.
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";
30import UserMeta from "./UserMeta";
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 if (data.uri && !data.color && !data.description) {
95 getEditHistory(data.uri)
96 .then((history) => {
97 if (history && history.length > 0) {
98 setHasEditHistory(true);
99 }
100 })
101 .catch(() => {});
102 }
103 }, [data.uri, data.color, data.description]);
104
105 const fetchHistory = async () => {
106 if (showHistory) {
107 setShowHistory(false);
108 return;
109 }
110 try {
111 setLoadingHistory(true);
112 setShowHistory(true);
113 const history = await getEditHistory(data.uri);
114 setEditHistory(history);
115 } catch (err) {
116 console.error("Failed to fetch history:", err);
117 } finally {
118 setLoadingHistory(false);
119 }
120 };
121
122 const handlePostReply = async (parentReply) => {
123 if (!replyText.trim()) return;
124
125 try {
126 setPosting(true);
127 const parentUri = parentReply
128 ? parentReply.id || parentReply.uri
129 : data.uri;
130 const parentCid = parentReply
131 ? parentReply.cid
132 : annotation.cid || data.cid;
133
134 await createReply({
135 parentUri,
136 parentCid: parentCid || "",
137 rootUri: data.uri,
138 rootCid: annotation.cid || data.cid || "",
139 text: replyText,
140 });
141
142 setReplyText("");
143 setReplyingTo(null);
144
145 const res = await getReplies(data.uri);
146 if (res.items) {
147 setReplies(res.items);
148 setReplyCount(res.items.length);
149 }
150 } catch (err) {
151 alert("Failed to post reply: " + err.message);
152 } finally {
153 setPosting(false);
154 }
155 };
156
157 const handleSaveEdit = async () => {
158 try {
159 setSaving(true);
160 const tagList = editTags
161 .split(",")
162 .map((t) => t.trim())
163 .filter(Boolean);
164 await updateAnnotation(data.uri, editText, tagList);
165 setIsEditing(false);
166 if (annotation.body) annotation.body.value = editText;
167 else if (annotation.text) annotation.text = editText;
168 if (annotation.tags) annotation.tags = tagList;
169 data.tags = tagList;
170 } catch (err) {
171 alert("Failed to update: " + err.message);
172 } finally {
173 setSaving(false);
174 }
175 };
176
177 const highlightedText =
178 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
179 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
180
181 const handleLike = async () => {
182 if (!user) {
183 login();
184 return;
185 }
186 try {
187 if (isLiked) {
188 setIsLiked(false);
189 setLikeCount((prev) => Math.max(0, prev - 1));
190 await unlikeAnnotation(data.uri);
191 } else {
192 setIsLiked(true);
193 setLikeCount((prev) => prev + 1);
194 const cid = annotation.cid || data.cid || "";
195 if (data.uri && cid) await likeAnnotation(data.uri, cid);
196 }
197 } catch (err) {
198 setIsLiked(!isLiked);
199 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1));
200 console.error("Failed to toggle like:", err);
201 }
202 };
203
204 const handleDelete = async () => {
205 if (!confirm("Delete this annotation? This cannot be undone.")) return;
206 try {
207 setDeleting(true);
208 const parts = data.uri.split("/");
209 const rkey = parts[parts.length - 1];
210 await deleteAnnotation(rkey);
211 if (onDelete) onDelete(data.uri);
212 else window.location.reload();
213 } catch (err) {
214 alert("Failed to delete: " + err.message);
215 } finally {
216 setDeleting(false);
217 }
218 };
219
220 return (
221 <article className="card annotation-card">
222 <header className="annotation-header">
223 <div className="annotation-header-left">
224 <UserMeta author={data.author} createdAt={data.createdAt} />
225 </div>
226 <div className="annotation-header-right">
227 <div style={{ display: "flex", gap: "4px" }}>
228 {hasEditHistory && !data.color && !data.description && (
229 <button
230 className="annotation-action action-icon-only"
231 onClick={fetchHistory}
232 title="View Edit History"
233 >
234 <Clock size={16} />
235 </button>
236 )}
237
238 {isOwner && (
239 <>
240 {!data.color && !data.description && (
241 <button
242 className="annotation-action action-icon-only"
243 onClick={() => setIsEditing(!isEditing)}
244 title="Edit"
245 >
246 <Edit2 size={16} />
247 </button>
248 )}
249 <button
250 className="annotation-action action-icon-only"
251 onClick={handleDelete}
252 disabled={deleting}
253 title="Delete"
254 >
255 <Trash2 size={16} />
256 </button>
257 </>
258 )}
259 </div>
260 </div>
261 </header>
262
263 {showHistory && (
264 <div className="history-panel">
265 <div className="history-header">
266 <h4 className="history-title">Edit History</h4>
267 <button
268 className="history-close-btn"
269 onClick={() => setShowHistory(false)}
270 title="Close History"
271 >
272 <X size={14} />
273 </button>
274 </div>
275 {loadingHistory ? (
276 <div className="history-status">Loading history...</div>
277 ) : editHistory.length === 0 ? (
278 <div className="history-status">No edit history found.</div>
279 ) : (
280 <ul className="history-list">
281 {editHistory.map((edit) => (
282 <li key={edit.id} className="history-item">
283 <div className="history-date">
284 {new Date(edit.editedAt).toLocaleString()}
285 </div>
286 <div className="history-content">{edit.previousContent}</div>
287 </li>
288 ))}
289 </ul>
290 )}
291 </div>
292 )}
293
294 <div className="annotation-content">
295 <a
296 href={data.url}
297 target="_blank"
298 rel="noopener noreferrer"
299 className="annotation-source"
300 >
301 {truncateUrl(data.url)}
302 {data.title && (
303 <span className="annotation-source-title"> • {data.title}</span>
304 )}
305 </a>
306
307 {highlightedText && (
308 <a
309 href={fragmentUrl}
310 target="_blank"
311 rel="noopener noreferrer"
312 className="annotation-highlight"
313 style={{
314 borderLeftColor: data.color || "var(--accent)",
315 }}
316 >
317 <mark>"{highlightedText}"</mark>
318 </a>
319 )}
320
321 {isEditing ? (
322 <div className="mt-3">
323 <textarea
324 value={editText}
325 onChange={(e) => setEditText(e.target.value)}
326 className="reply-input"
327 rows={3}
328 style={{ marginBottom: "8px" }}
329 />
330 <input
331 type="text"
332 className="reply-input"
333 placeholder="Tags (comma separated)..."
334 value={editTags}
335 onChange={(e) => setEditTags(e.target.value)}
336 style={{ marginBottom: "8px" }}
337 />
338 <div className="action-buttons-end">
339 <button
340 onClick={() => setIsEditing(false)}
341 className="btn btn-ghost"
342 >
343 Cancel
344 </button>
345 <button
346 onClick={handleSaveEdit}
347 disabled={saving}
348 className="btn btn-primary btn-sm"
349 >
350 {saving ? (
351 "Saving..."
352 ) : (
353 <>
354 <Save size={14} /> Save
355 </>
356 )}
357 </button>
358 </div>
359 </div>
360 ) : (
361 data.text && <p className="annotation-text">{data.text}</p>
362 )}
363
364 {data.tags?.length > 0 && (
365 <div className="annotation-tags">
366 {data.tags.map((tag, i) => (
367 <Link
368 key={i}
369 to={`/?tag=${encodeURIComponent(tag)}`}
370 className="annotation-tag"
371 >
372 #{tag}
373 </Link>
374 ))}
375 </div>
376 )}
377 </div>
378
379 <footer className="annotation-actions">
380 <div className="annotation-actions-left">
381 <button
382 className={`annotation-action ${isLiked ? "liked" : ""}`}
383 onClick={handleLike}
384 >
385 <Heart filled={isLiked} size={16} />
386 {likeCount > 0 && <span>{likeCount}</span>}
387 </button>
388 <button
389 className={`annotation-action ${showReplies ? "active" : ""}`}
390 onClick={async () => {
391 if (!showReplies && replies.length === 0) {
392 try {
393 const res = await getReplies(data.uri);
394 if (res.items) setReplies(res.items);
395 } catch (err) {
396 console.error("Failed to load replies:", err);
397 }
398 }
399 setShowReplies(!showReplies);
400 }}
401 >
402 <MessageSquare size={16} />
403 <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
404 </button>
405 <ShareMenu
406 uri={data.uri}
407 text={data.title || data.url}
408 handle={data.author?.handle}
409 type="Annotation"
410 />
411 <button
412 className="annotation-action"
413 onClick={() => {
414 if (!user) {
415 login();
416 return;
417 }
418 if (onAddToCollection) onAddToCollection();
419 }}
420 >
421 <Folder size={16} />
422 <span>Collect</span>
423 </button>
424 </div>
425 </footer>
426
427 {showReplies && (
428 <div className="inline-replies">
429 <ReplyList
430 replies={replies}
431 rootUri={data.uri}
432 user={user}
433 onReply={(reply) => setReplyingTo(reply)}
434 onDelete={async (reply) => {
435 if (!confirm("Delete this reply?")) return;
436 try {
437 await deleteReply(reply.id || reply.uri);
438 const res = await getReplies(data.uri);
439 if (res.items) {
440 setReplies(res.items);
441 setReplyCount(res.items.length);
442 }
443 } catch (err) {
444 alert("Failed to delete: " + err.message);
445 }
446 }}
447 isInline={true}
448 />
449
450 <div className="reply-form">
451 {replyingTo && (
452 <div
453 style={{
454 display: "flex",
455 alignItems: "center",
456 gap: "8px",
457 marginBottom: "8px",
458 fontSize: "0.85rem",
459 color: "var(--text-secondary)",
460 }}
461 >
462 <span>
463 Replying to @
464 {(replyingTo.creator || replyingTo.author)?.handle ||
465 "unknown"}
466 </span>
467 <button
468 onClick={() => setReplyingTo(null)}
469 style={{
470 background: "none",
471 border: "none",
472 color: "var(--text-tertiary)",
473 cursor: "pointer",
474 padding: "2px 6px",
475 }}
476 >
477 ×
478 </button>
479 </div>
480 )}
481 <textarea
482 className="reply-input"
483 placeholder={
484 replyingTo
485 ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...`
486 : "Write a reply..."
487 }
488 value={replyText}
489 onChange={(e) => setReplyText(e.target.value)}
490 onFocus={(e) => {
491 if (!user) {
492 e.preventDefault();
493 alert("Please sign in to like annotations");
494 }
495 }}
496 rows={2}
497 />
498 <div className="action-buttons-end">
499 <button
500 className="btn btn-primary btn-sm"
501 disabled={posting || !replyText.trim()}
502 onClick={() => {
503 if (!user) {
504 login();
505 return;
506 }
507 handlePostReply(replyingTo);
508 }}
509 >
510 {posting ? "Posting..." : "Reply"}
511 </button>
512 </div>
513 </div>
514 </div>
515 )}
516 </article>
517 );
518}
519
520export function HighlightCard({
521 highlight,
522 onDelete,
523 onAddToCollection,
524 onUpdate,
525}) {
526 const { user, login } = useAuth();
527 const data = normalizeHighlight(highlight);
528 const highlightedText =
529 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
530 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
531 const isOwner = user?.did && data.author?.did === user.did;
532 const [isEditing, setIsEditing] = useState(false);
533 const [editColor, setEditColor] = useState(data.color || "#f59e0b");
534 const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
535
536 const handleSaveEdit = async () => {
537 try {
538 const tagList = editTags
539 .split(",")
540 .map((t) => t.trim())
541 .filter(Boolean);
542
543 await updateHighlight(data.uri, editColor, tagList);
544 setIsEditing(false);
545 if (typeof onUpdate === "function")
546 onUpdate({ ...highlight, color: editColor, tags: tagList });
547 } catch (err) {
548 alert("Failed to update: " + err.message);
549 }
550 };
551
552 return (
553 <article className="card annotation-card">
554 <header className="annotation-header">
555 <div className="annotation-header-left">
556 <UserMeta author={data.author} createdAt={data.createdAt} />
557 </div>
558
559 <div className="annotation-header-right">
560 <div style={{ display: "flex", gap: "4px" }}>
561 {isOwner && (
562 <>
563 <button
564 className="annotation-action action-icon-only"
565 onClick={() => setIsEditing(!isEditing)}
566 title="Edit Color"
567 >
568 <Edit2 size={16} />
569 </button>
570 <button
571 className="annotation-action action-icon-only"
572 onClick={(e) => {
573 e.preventDefault();
574 onDelete && onDelete(highlight.id || highlight.uri);
575 }}
576 >
577 <TrashIcon size={16} />
578 </button>
579 </>
580 )}
581 </div>
582 </div>
583 </header>
584
585 <div className="annotation-content">
586 <a
587 href={data.url}
588 target="_blank"
589 rel="noopener noreferrer"
590 className="annotation-source"
591 >
592 {truncateUrl(data.url)}
593 </a>
594
595 {highlightedText && (
596 <a
597 href={fragmentUrl}
598 target="_blank"
599 rel="noopener noreferrer"
600 className="annotation-highlight"
601 style={{
602 borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
603 }}
604 >
605 <mark>"{highlightedText}"</mark>
606 </a>
607 )}
608
609 {isEditing && (
610 <div
611 className="mt-3"
612 style={{
613 display: "flex",
614 gap: "8px",
615 alignItems: "center",
616 padding: "8px",
617 background: "var(--bg-secondary)",
618 borderRadius: "var(--radius-md)",
619 border: "1px solid var(--border)",
620 }}
621 >
622 <div
623 className="color-picker-compact"
624 style={{
625 position: "relative",
626 width: "28px",
627 height: "28px",
628 flexShrink: 0,
629 }}
630 >
631 <div
632 style={{
633 backgroundColor: editColor,
634 width: "100%",
635 height: "100%",
636 borderRadius: "50%",
637 border: "2px solid var(--bg-card)",
638 boxShadow: "0 0 0 1px var(--border)",
639 }}
640 />
641 <input
642 type="color"
643 value={editColor}
644 onChange={(e) => setEditColor(e.target.value)}
645 style={{
646 position: "absolute",
647 top: 0,
648 left: 0,
649 width: "100%",
650 height: "100%",
651 opacity: 0,
652 cursor: "pointer",
653 }}
654 title="Change Color"
655 />
656 </div>
657
658 <input
659 type="text"
660 className="reply-input"
661 placeholder="e.g. tag1, tag2"
662 value={editTags}
663 onChange={(e) => setEditTags(e.target.value)}
664 style={{
665 margin: 0,
666 flex: 1,
667 fontSize: "0.9rem",
668 padding: "6px 10px",
669 height: "32px",
670 border: "none",
671 background: "transparent",
672 }}
673 />
674
675 <button
676 onClick={handleSaveEdit}
677 className="btn btn-primary btn-sm"
678 style={{ padding: "0 10px", height: "32px", minWidth: "auto" }}
679 title="Save"
680 >
681 <Save size={16} />
682 </button>
683 </div>
684 )}
685
686 {data.tags?.length > 0 && (
687 <div className="annotation-tags">
688 {data.tags.map((tag, i) => (
689 <Link
690 key={i}
691 to={`/?tag=${encodeURIComponent(tag)}`}
692 className="annotation-tag"
693 >
694 #{tag}
695 </Link>
696 ))}
697 </div>
698 )}
699 </div>
700
701 <footer className="annotation-actions">
702 <div className="annotation-actions-left">
703 <span
704 className="annotation-action"
705 style={{
706 color: data.color || "#f59e0b",
707 background: "none",
708 paddingLeft: 0,
709 }}
710 >
711 <HighlightIcon size={14} /> Highlight
712 </span>
713 <ShareMenu
714 uri={data.uri}
715 text={data.title || data.description}
716 handle={data.author?.handle}
717 type="Highlight"
718 />
719 <button
720 className="annotation-action"
721 onClick={() => {
722 if (!user) {
723 login();
724 return;
725 }
726 if (onAddToCollection) onAddToCollection();
727 }}
728 >
729 <Folder size={16} />
730 <span>Collect</span>
731 </button>
732 </div>
733 </footer>
734 </article>
735 );
736}