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