Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect } from "react";
2import { useAuth } from "../context/AuthContext";
3import ReplyList from "./ReplyList";
4import { Link } from "react-router-dom";
5import RichText from "./RichText";
6import {
7 normalizeAnnotation,
8 normalizeHighlight,
9 likeAnnotation,
10 unlikeAnnotation,
11 getReplies,
12 createReply,
13 deleteReply,
14 updateAnnotation,
15 updateHighlight,
16 getEditHistory,
17 deleteAnnotation,
18} from "../api/client";
19import {
20 MessageSquare,
21 Heart,
22 Trash2,
23 Folder,
24 Edit2,
25 Save,
26 X,
27 Clock,
28} from "lucide-react";
29import { HighlightIcon, TrashIcon } from "./Icons";
30import ShareMenu from "./ShareMenu";
31import UserMeta from "./UserMeta";
32
33function buildTextFragmentUrl(baseUrl, selector) {
34 if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) {
35 return baseUrl;
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 return baseUrl + "#" + fragment;
46}
47
48const truncateUrl = (url, maxLength = 50) => {
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
61function SembleBadge() {
62 return (
63 <div className="semble-badge" title="Added using Semble">
64 <span>via Semble</span>
65 <img src="/semble-logo.svg" alt="Semble" />
66 </div>
67 );
68}
69
70export default function AnnotationCard({
71 annotation,
72 onDelete,
73 onAddToCollection,
74}) {
75 const { user, login } = useAuth();
76 const data = normalizeAnnotation(annotation);
77
78 const [likeCount, setLikeCount] = useState(data.likeCount || 0);
79 const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false);
80 const [deleting, setDeleting] = useState(false);
81 const [isEditing, setIsEditing] = useState(false);
82 const [editText, setEditText] = useState(data.text || "");
83 const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
84 const [saving, setSaving] = useState(false);
85 const [showHistory, setShowHistory] = useState(false);
86 const [editHistory, setEditHistory] = useState([]);
87 const [loadingHistory, setLoadingHistory] = useState(false);
88 const [replies, setReplies] = useState([]);
89 const [replyCount, setReplyCount] = useState(data.replyCount || 0);
90 const [showReplies, setShowReplies] = useState(false);
91 const [replyingTo, setReplyingTo] = useState(null);
92 const [replyText, setReplyText] = useState("");
93 const [posting, setPosting] = useState(false);
94 const [hasEditHistory, setHasEditHistory] = useState(false);
95
96 const isOwner = user?.did && data.author?.did === user.did;
97 const isSemble = data.uri?.includes("network.cosmik");
98 const highlightedText =
99 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
100 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
101
102 useEffect(() => {
103 if (data.uri && !data.color && !data.description) {
104 getEditHistory(data.uri)
105 .then((history) => {
106 if (history?.length > 0) setHasEditHistory(true);
107 })
108 .catch(() => {});
109 }
110 }, [data.uri, data.color, data.description]);
111
112 const fetchHistory = async () => {
113 if (showHistory) {
114 setShowHistory(false);
115 return;
116 }
117 try {
118 setLoadingHistory(true);
119 setShowHistory(true);
120 const history = await getEditHistory(data.uri);
121 setEditHistory(history);
122 } catch (err) {
123 console.error("Failed to fetch history:", err);
124 } finally {
125 setLoadingHistory(false);
126 }
127 };
128
129 const handlePostReply = async (parentReply) => {
130 if (!replyText.trim()) return;
131 try {
132 setPosting(true);
133 const parentUri = parentReply
134 ? parentReply.id || parentReply.uri
135 : data.uri;
136 const parentCid = parentReply
137 ? parentReply.cid
138 : annotation.cid || data.cid;
139
140 await createReply({
141 parentUri,
142 parentCid: parentCid || "",
143 rootUri: data.uri,
144 rootCid: annotation.cid || data.cid || "",
145 text: replyText,
146 });
147
148 setReplyText("");
149 setReplyingTo(null);
150
151 const res = await getReplies(data.uri);
152 if (res.items) {
153 setReplies(res.items);
154 setReplyCount(res.items.length);
155 }
156 } catch (err) {
157 alert("Failed to post reply: " + err.message);
158 } finally {
159 setPosting(false);
160 }
161 };
162
163 const handleSaveEdit = async () => {
164 try {
165 setSaving(true);
166 const tagList = editTags
167 .split(",")
168 .map((t) => t.trim())
169 .filter(Boolean);
170 await updateAnnotation(data.uri, editText, tagList);
171 setIsEditing(false);
172 if (annotation.body) annotation.body.value = editText;
173 else if (annotation.text) annotation.text = editText;
174 if (annotation.tags) annotation.tags = tagList;
175 data.tags = tagList;
176 } catch (err) {
177 alert("Failed to update: " + err.message);
178 } finally {
179 setSaving(false);
180 }
181 };
182
183 const handleLike = async () => {
184 if (!user) {
185 login();
186 return;
187 }
188 try {
189 if (isLiked) {
190 setIsLiked(false);
191 setLikeCount((prev) => Math.max(0, prev - 1));
192 await unlikeAnnotation(data.uri);
193 } else {
194 setIsLiked(true);
195 setLikeCount((prev) => prev + 1);
196 const cid = annotation.cid || data.cid || "";
197 if (data.uri && cid) await likeAnnotation(data.uri, cid);
198 }
199 } catch {
200 setIsLiked(!isLiked);
201 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1));
202 }
203 };
204
205 const handleDelete = async () => {
206 if (!confirm("Delete this annotation? This cannot be undone.")) return;
207 try {
208 setDeleting(true);
209 const parts = data.uri.split("/");
210 const rkey = parts[parts.length - 1];
211 await deleteAnnotation(rkey);
212 if (onDelete) onDelete(data.uri);
213 else window.location.reload();
214 } catch (err) {
215 alert("Failed to delete: " + err.message);
216 } finally {
217 setDeleting(false);
218 }
219 };
220
221 const loadReplies = async () => {
222 if (!showReplies && replies.length === 0) {
223 try {
224 const res = await getReplies(data.uri);
225 if (res.items) setReplies(res.items);
226 } catch (err) {
227 console.error("Failed to load replies:", err);
228 }
229 }
230 setShowReplies(!showReplies);
231 };
232
233 const handleCollect = () => {
234 if (!user) {
235 login();
236 return;
237 }
238 if (onAddToCollection) onAddToCollection();
239 };
240
241 return (
242 <article className="card annotation-card">
243 <header className="annotation-header">
244 <div className="annotation-header-left">
245 <UserMeta author={data.author} createdAt={data.createdAt} />
246 </div>
247 <div className="annotation-header-right">
248 {isSemble && <SembleBadge />}
249 {hasEditHistory && !data.color && !data.description && (
250 <button
251 className="annotation-action action-icon-only"
252 onClick={fetchHistory}
253 title="View Edit History"
254 >
255 <Clock size={16} />
256 </button>
257 )}
258 {isOwner && !isSemble && (
259 <>
260 {!data.color && !data.description && (
261 <button
262 className="annotation-action action-icon-only"
263 onClick={() => setIsEditing(!isEditing)}
264 title="Edit"
265 >
266 <Edit2 size={16} />
267 </button>
268 )}
269 <button
270 className="annotation-action action-icon-only"
271 onClick={handleDelete}
272 disabled={deleting}
273 title="Delete"
274 >
275 <Trash2 size={16} />
276 </button>
277 </>
278 )}
279 </div>
280 </header>
281
282 {showHistory && (
283 <div className="history-panel">
284 <div className="history-header">
285 <h4 className="history-title">Edit History</h4>
286 <button
287 className="annotation-action action-icon-only"
288 onClick={() => setShowHistory(false)}
289 >
290 <X size={14} />
291 </button>
292 </div>
293 {loadingHistory ? (
294 <div className="history-status">Loading history...</div>
295 ) : editHistory.length === 0 ? (
296 <div className="history-status">No edit history found.</div>
297 ) : (
298 <ul className="history-list">
299 {editHistory.map((edit) => (
300 <li key={edit.id} className="history-item">
301 <div className="history-date">
302 {new Date(edit.editedAt).toLocaleString()}
303 </div>
304 <div className="history-content">{edit.previousContent}</div>
305 </li>
306 ))}
307 </ul>
308 )}
309 </div>
310 )}
311
312 <div className="annotation-content">
313 <a
314 href={data.url}
315 target="_blank"
316 rel="noopener noreferrer"
317 className="annotation-source"
318 >
319 {truncateUrl(data.url)}
320 {data.title && (
321 <span className="annotation-source-title"> · {data.title}</span>
322 )}
323 </a>
324
325 {highlightedText && (
326 <a
327 href={fragmentUrl}
328 target="_blank"
329 rel="noopener noreferrer"
330 className="annotation-highlight"
331 style={{ borderLeftColor: data.color || "var(--accent)" }}
332 >
333 <mark>“{highlightedText}”</mark>
334 </a>
335 )}
336
337 {isEditing ? (
338 <div className="edit-form">
339 <textarea
340 value={editText}
341 onChange={(e) => setEditText(e.target.value)}
342 className="reply-input"
343 rows={3}
344 placeholder="Your annotation..."
345 />
346 <input
347 type="text"
348 className="reply-input"
349 placeholder="Tags (comma separated)..."
350 value={editTags}
351 onChange={(e) => setEditTags(e.target.value)}
352 style={{ marginTop: "8px" }}
353 />
354 <div className="action-buttons-end" style={{ marginTop: "8px" }}>
355 <button
356 onClick={() => setIsEditing(false)}
357 className="btn btn-ghost"
358 >
359 Cancel
360 </button>
361 <button
362 onClick={handleSaveEdit}
363 disabled={saving}
364 className="btn btn-primary"
365 >
366 {saving ? (
367 "Saving..."
368 ) : (
369 <>
370 <Save size={14} /> Save
371 </>
372 )}
373 </button>
374 </div>
375 </div>
376 ) : (
377 <RichText text={data.text} facets={data.facets} />
378 )}
379
380 {data.tags?.length > 0 && (
381 <div className="annotation-tags">
382 {data.tags.map((tag, i) => (
383 <Link
384 key={i}
385 to={`/?tag=${encodeURIComponent(tag)}`}
386 className="annotation-tag"
387 >
388 #{tag}
389 </Link>
390 ))}
391 </div>
392 )}
393 </div>
394
395 <footer className="annotation-actions">
396 <div className="annotation-actions-left">
397 <button
398 className={`annotation-action ${isLiked ? "liked" : ""}`}
399 onClick={handleLike}
400 >
401 <Heart size={16} fill={isLiked ? "currentColor" : "none"} />
402 {likeCount > 0 && <span>{likeCount}</span>}
403 </button>
404
405 <button
406 className={`annotation-action ${showReplies ? "active" : ""}`}
407 onClick={loadReplies}
408 >
409 <MessageSquare size={16} />
410 <span>{replyCount > 0 ? replyCount : "Reply"}</span>
411 </button>
412
413 <ShareMenu
414 uri={data.uri}
415 text={data.title || data.url}
416 handle={data.author?.handle}
417 type="Annotation"
418 url={data.url}
419 />
420
421 <button className="annotation-action" onClick={handleCollect}>
422 <Folder size={16} />
423 <span>Collect</span>
424 </button>
425 </div>
426 </footer>
427
428 {showReplies && (
429 <div className="inline-replies">
430 <ReplyList
431 replies={replies}
432 rootUri={data.uri}
433 user={user}
434 onReply={(reply) => setReplyingTo(reply)}
435 onDelete={async (reply) => {
436 if (!confirm("Delete this reply?")) return;
437 try {
438 await deleteReply(reply.id || reply.uri);
439 const res = await getReplies(data.uri);
440 if (res.items) {
441 setReplies(res.items);
442 setReplyCount(res.items.length);
443 }
444 } catch (err) {
445 alert("Failed to delete: " + err.message);
446 }
447 }}
448 isInline={true}
449 />
450
451 <div className="reply-form">
452 {replyingTo && (
453 <div className="replying-to-banner">
454 <span>
455 Replying to @
456 {(replyingTo.creator || replyingTo.author)?.handle ||
457 "unknown"}
458 </span>
459 <button
460 onClick={() => setReplyingTo(null)}
461 className="cancel-reply"
462 >
463 ×
464 </button>
465 </div>
466 )}
467 <textarea
468 className="reply-input"
469 placeholder={
470 replyingTo
471 ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...`
472 : "Write a reply..."
473 }
474 value={replyText}
475 onChange={(e) => setReplyText(e.target.value)}
476 rows={2}
477 />
478 <div className="reply-form-actions">
479 <button
480 className="btn btn-primary"
481 disabled={posting || !replyText.trim()}
482 onClick={() => {
483 if (!user) {
484 login();
485 return;
486 }
487 handlePostReply(replyingTo);
488 }}
489 >
490 {posting ? "Posting..." : "Reply"}
491 </button>
492 </div>
493 </div>
494 </div>
495 )}
496 </article>
497 );
498}
499
500export function HighlightCard({
501 highlight,
502 onDelete,
503 onAddToCollection,
504 onUpdate,
505}) {
506 const { user, login } = useAuth();
507 const data = normalizeHighlight(highlight);
508 const highlightedText =
509 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
510 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
511 const isOwner = user?.did && data.author?.did === user.did;
512 const isSemble = data.uri?.includes("network.cosmik");
513
514 const [isEditing, setIsEditing] = useState(false);
515 const [editColor, setEditColor] = useState(data.color || "#f59e0b");
516 const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
517
518 const handleSaveEdit = async () => {
519 try {
520 const tagList = editTags
521 .split(",")
522 .map((t) => t.trim())
523 .filter(Boolean);
524 await updateHighlight(data.uri, editColor, tagList);
525 setIsEditing(false);
526 if (typeof onUpdate === "function") {
527 onUpdate({ ...highlight, color: editColor, tags: tagList });
528 }
529 } catch (err) {
530 alert("Failed to update: " + err.message);
531 }
532 };
533
534 const handleCollect = () => {
535 if (!user) {
536 login();
537 return;
538 }
539 if (onAddToCollection) onAddToCollection();
540 };
541
542 return (
543 <article className="card annotation-card">
544 <header className="annotation-header">
545 <div className="annotation-header-left">
546 <UserMeta author={data.author} createdAt={data.createdAt} />
547 </div>
548 <div className="annotation-header-right">
549 {isSemble && (
550 <div className="semble-badge" title="Added using Semble">
551 <span>via Semble</span>
552 <img src="/semble-logo.svg" alt="Semble" />
553 </div>
554 )}
555 {isOwner && (
556 <>
557 <button
558 className="annotation-action action-icon-only"
559 onClick={() => setIsEditing(!isEditing)}
560 title="Edit Color"
561 >
562 <Edit2 size={16} />
563 </button>
564 <button
565 className="annotation-action action-icon-only"
566 onClick={(e) => {
567 e.preventDefault();
568 onDelete && onDelete(highlight.id || highlight.uri);
569 }}
570 title="Delete"
571 >
572 <TrashIcon size={16} />
573 </button>
574 </>
575 )}
576 </div>
577 </header>
578
579 <div className="annotation-content">
580 <a
581 href={data.url}
582 target="_blank"
583 rel="noopener noreferrer"
584 className="annotation-source"
585 >
586 {truncateUrl(data.url)}
587 </a>
588
589 {highlightedText && (
590 <a
591 href={fragmentUrl}
592 target="_blank"
593 rel="noopener noreferrer"
594 className="annotation-highlight"
595 style={{
596 borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
597 }}
598 >
599 <mark>“{highlightedText}”</mark>
600 </a>
601 )}
602
603 {isEditing && (
604 <div className="color-edit-form">
605 <div className="color-picker-wrapper">
606 <div
607 className="color-preview"
608 style={{ backgroundColor: editColor }}
609 />
610 <input
611 type="color"
612 value={editColor}
613 onChange={(e) => setEditColor(e.target.value)}
614 className="color-input"
615 />
616 </div>
617 <input
618 type="text"
619 className="reply-input"
620 placeholder="Tags (comma separated)"
621 value={editTags}
622 onChange={(e) => setEditTags(e.target.value)}
623 style={{ flex: 1, margin: 0 }}
624 />
625 <button
626 onClick={handleSaveEdit}
627 className="btn btn-primary"
628 style={{ padding: "0 12px", height: "32px" }}
629 >
630 <Save size={16} />
631 </button>
632 </div>
633 )}
634
635 {data.tags?.length > 0 && (
636 <div className="annotation-tags">
637 {data.tags.map((tag, i) => (
638 <Link
639 key={i}
640 to={`/?tag=${encodeURIComponent(tag)}`}
641 className="annotation-tag"
642 >
643 #{tag}
644 </Link>
645 ))}
646 </div>
647 )}
648 </div>
649
650 <footer className="annotation-actions">
651 <div className="annotation-actions-left">
652 <span
653 className="annotation-action"
654 style={{ color: data.color || "#f59e0b", cursor: "default" }}
655 >
656 <HighlightIcon size={14} /> Highlight
657 </span>
658
659 <ShareMenu
660 uri={data.uri}
661 text={data.title || data.description}
662 handle={data.author?.handle}
663 type="Highlight"
664 />
665
666 <button className="annotation-action" onClick={handleCollect}>
667 <Folder size={16} />
668 <span>Collect</span>
669 </button>
670 </div>
671 </footer>
672 </article>
673 );
674}