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 {
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", alignItems: "center" }}>
109 {data.uri && data.uri.includes("network.cosmik") && (
110 <div
111 style={{
112 display: "flex",
113 alignItems: "center",
114 gap: "4px",
115 fontSize: "0.75rem",
116 color: "var(--text-tertiary)",
117 marginRight: "8px",
118 }}
119 title="Added using Semble"
120 >
121 <span>via Semble</span>
122 <img
123 src="/semble-logo.svg"
124 alt="Semble"
125 style={{ width: "16px", height: "16px" }}
126 />
127 </div>
128 )}
129 <div style={{ display: "flex", gap: "4px" }}>
130 {((isOwner &&
131 !(data.uri && data.uri.includes("network.cosmik"))) ||
132 onDelete) && (
133 <button
134 className="annotation-action action-icon-only"
135 onClick={handleDelete}
136 disabled={deleting}
137 title="Delete"
138 >
139 <TrashIcon size={16} />
140 </button>
141 )}
142 </div>
143 </div>
144 </div>
145 </header>
146
147 <div className="annotation-content">
148 <a
149 href={data.url}
150 target="_blank"
151 rel="noopener noreferrer"
152 className="bookmark-preview"
153 >
154 <div className="bookmark-preview-content">
155 <div className="bookmark-preview-site">
156 <BookmarkIcon size={14} />
157 <span>{domain}</span>
158 </div>
159 <h3 className="bookmark-preview-title">{data.title || data.url}</h3>
160 {data.description && (
161 <p className="bookmark-preview-desc">{data.description}</p>
162 )}
163 </div>
164 </a>
165
166 {data.tags?.length > 0 && (
167 <div className="annotation-tags">
168 {data.tags.map((tag, i) => (
169 <span key={i} className="annotation-tag">
170 #{tag}
171 </span>
172 ))}
173 </div>
174 )}
175 </div>
176
177 <footer className="annotation-actions">
178 <div className="annotation-actions-left">
179 <button
180 className={`annotation-action ${isLiked ? "liked" : ""}`}
181 onClick={handleLike}
182 >
183 <HeartIcon filled={isLiked} size={16} />
184 {likeCount > 0 && <span>{likeCount}</span>}
185 </button>
186 <ShareMenu
187 uri={data.uri}
188 text={data.title || data.description}
189 handle={data.author?.handle}
190 type="Bookmark"
191 url={data.url}
192 />
193 <button
194 className="annotation-action"
195 onClick={() => {
196 if (!user) {
197 login();
198 return;
199 }
200 if (onAddToCollection) onAddToCollection();
201 }}
202 >
203 <Folder size={16} />
204 <span>Collect</span>
205 </button>
206 </div>
207 </footer>
208 </article>
209 );
210}