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 {
4 normalizeAnnotation,
5 normalizeBookmark,
6 likeAnnotation,
7 unlikeAnnotation,
8 getLikeCount,
9 deleteBookmark,
10} from "../api/client";
11import { HeartIcon, TrashIcon, BookmarkIcon } from "./Icons";
12import { Folder } from "lucide-react";
13import ShareMenu from "./ShareMenu";
14import UserMeta from "./UserMeta";
15
16export default function BookmarkCard({
17 bookmark,
18 onAddToCollection,
19 onDelete,
20}) {
21 const { user, login } = useAuth();
22 const raw = bookmark;
23 const data =
24 raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw);
25
26 const [likeCount, setLikeCount] = useState(0);
27 const [isLiked, setIsLiked] = useState(false);
28 const [deleting, setDeleting] = useState(false);
29
30 const isOwner = user?.did && data.author?.did === user.did;
31
32 useEffect(() => {
33 let mounted = true;
34 async function fetchData() {
35 try {
36 const likeRes = await getLikeCount(data.uri);
37 if (mounted) {
38 if (likeRes.count !== undefined) setLikeCount(likeRes.count);
39 if (likeRes.liked !== undefined) setIsLiked(likeRes.liked);
40 }
41 } catch {
42 /* ignore */
43 }
44 }
45 if (data.uri) fetchData();
46 return () => {
47 mounted = false;
48 };
49 }, [data.uri]);
50
51 const handleLike = async () => {
52 if (!user) {
53 login();
54 return;
55 }
56 try {
57 if (isLiked) {
58 setIsLiked(false);
59 setLikeCount((prev) => Math.max(0, prev - 1));
60 await unlikeAnnotation(data.uri);
61 } else {
62 setIsLiked(true);
63 setLikeCount((prev) => prev + 1);
64 const cid = data.cid || "";
65 if (data.uri && cid) await likeAnnotation(data.uri, cid);
66 }
67 } catch {
68 setIsLiked(!isLiked);
69 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1));
70 }
71 };
72
73 const handleDelete = async () => {
74 if (onDelete) {
75 onDelete(data.uri);
76 return;
77 }
78
79 if (!confirm("Delete this bookmark?")) return;
80 try {
81 setDeleting(true);
82 const parts = data.uri.split("/");
83 const rkey = parts[parts.length - 1];
84 await deleteBookmark(rkey);
85 window.location.reload();
86 } catch (err) {
87 alert("Failed to delete: " + err.message);
88 } finally {
89 setDeleting(false);
90 }
91 };
92
93 let domain = "";
94 try {
95 if (data.url) domain = new URL(data.url).hostname.replace("www.", "");
96 } catch {
97 /* ignore */
98 }
99
100 return (
101 <article className="card annotation-card bookmark-card">
102 <header className="annotation-header">
103 <div className="annotation-header-left">
104 <UserMeta author={data.author} createdAt={data.createdAt} />
105 </div>
106
107 <div className="annotation-header-right">
108 <div style={{ display: "flex", gap: "4px" }}>
109 {(isOwner || onDelete) && (
110 <button
111 className="annotation-action action-icon-only"
112 onClick={handleDelete}
113 disabled={deleting}
114 title="Delete"
115 >
116 <TrashIcon size={16} />
117 </button>
118 )}
119 </div>
120 </div>
121 </header>
122
123 <div className="annotation-content">
124 <a
125 href={data.url}
126 target="_blank"
127 rel="noopener noreferrer"
128 className="bookmark-preview"
129 >
130 <div className="bookmark-preview-content">
131 <div className="bookmark-preview-site">
132 <BookmarkIcon size={14} />
133 <span>{domain}</span>
134 </div>
135 <h3 className="bookmark-preview-title">{data.title || data.url}</h3>
136 {data.description && (
137 <p className="bookmark-preview-desc">{data.description}</p>
138 )}
139 </div>
140 </a>
141
142 {data.tags?.length > 0 && (
143 <div className="annotation-tags">
144 {data.tags.map((tag, i) => (
145 <span key={i} className="annotation-tag">
146 #{tag}
147 </span>
148 ))}
149 </div>
150 )}
151 </div>
152
153 <footer className="annotation-actions">
154 <div className="annotation-actions-left">
155 <button
156 className={`annotation-action ${isLiked ? "liked" : ""}`}
157 onClick={handleLike}
158 >
159 <HeartIcon filled={isLiked} size={16} />
160 {likeCount > 0 && <span>{likeCount}</span>}
161 </button>
162 <ShareMenu
163 uri={data.uri}
164 text={data.title || data.description}
165 handle={data.author?.handle}
166 type="Bookmark"
167 />
168 <button
169 className="annotation-action"
170 onClick={() => {
171 if (!user) {
172 login();
173 return;
174 }
175 if (onAddToCollection) onAddToCollection();
176 }}
177 >
178 <Folder size={16} />
179 <span>Collect</span>
180 </button>
181 </div>
182 </footer>
183 </article>
184 );
185}