1import { useState, useEffect } from "react";
2import { useAuth } from "../context/AuthContext";
3import { Link } from "react-router-dom";
4import {
5 normalizeAnnotation,
6 normalizeBookmark,
7 likeAnnotation,
8 unlikeAnnotation,
9 getLikeCount,
10 deleteBookmark,
11} from "../api/client";
12import { HeartIcon, TrashIcon, BookmarkIcon } from "./Icons";
13import { Folder } from "lucide-react";
14import ShareMenu from "./ShareMenu";
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 const formatDate = (dateString) => {
94 if (!dateString) return "";
95 const date = new Date(dateString);
96 const now = new Date();
97 const diff = now - date;
98 const minutes = Math.floor(diff / 60000);
99 const hours = Math.floor(diff / 3600000);
100 const days = Math.floor(diff / 86400000);
101 if (minutes < 1) return "just now";
102 if (minutes < 60) return `${minutes}m`;
103 if (hours < 24) return `${hours}h`;
104 if (days < 7) return `${days}d`;
105 return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
106 };
107
108 let domain = "";
109 try {
110 if (data.url) domain = new URL(data.url).hostname.replace("www.", "");
111 } catch {
112 /* ignore */
113 }
114
115 const authorDisplayName = data.author?.displayName || data.author?.handle;
116 const authorHandle = data.author?.handle;
117 const authorAvatar = data.author?.avatar;
118 const authorDid = data.author?.did;
119 const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null;
120
121 return (
122 <article className="card annotation-card bookmark-card">
123 <header className="annotation-header">
124 <div className="annotation-header-left">
125 <Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
126 <div className="annotation-avatar">
127 {authorAvatar ? (
128 <img src={authorAvatar} alt={authorDisplayName} />
129 ) : (
130 <span>
131 {(authorDisplayName || authorHandle || "??")
132 ?.substring(0, 2)
133 .toUpperCase()}
134 </span>
135 )}
136 </div>
137 </Link>
138 <div className="annotation-meta">
139 <div className="annotation-author-row">
140 <Link
141 to={marginProfileUrl || "#"}
142 className="annotation-author-link"
143 >
144 <span className="annotation-author">{authorDisplayName}</span>
145 </Link>
146 {authorHandle && (
147 <a
148 href={`https://bsky.app/profile/${authorHandle}`}
149 target="_blank"
150 rel="noopener noreferrer"
151 className="annotation-handle"
152 >
153 @{authorHandle}
154 </a>
155 )}
156 </div>
157 <div className="annotation-time">{formatDate(data.createdAt)}</div>
158 </div>
159 </div>
160
161 <div className="annotation-header-right">
162 <div style={{ display: "flex", gap: "4px" }}>
163 {(isOwner || onDelete) && (
164 <button
165 className="annotation-action action-icon-only"
166 onClick={handleDelete}
167 disabled={deleting}
168 title="Delete"
169 >
170 <TrashIcon size={16} />
171 </button>
172 )}
173 </div>
174 </div>
175 </header>
176
177 <div className="annotation-content">
178 <a
179 href={data.url}
180 target="_blank"
181 rel="noopener noreferrer"
182 className="bookmark-preview"
183 >
184 <div className="bookmark-preview-content">
185 <div className="bookmark-preview-site">
186 <BookmarkIcon size={14} />
187 <span>{domain}</span>
188 </div>
189 <h3 className="bookmark-preview-title">{data.title || data.url}</h3>
190 {data.description && (
191 <p className="bookmark-preview-desc">{data.description}</p>
192 )}
193 </div>
194 </a>
195
196 {data.tags?.length > 0 && (
197 <div className="annotation-tags">
198 {data.tags.map((tag, i) => (
199 <span key={i} className="annotation-tag">
200 #{tag}
201 </span>
202 ))}
203 </div>
204 )}
205 </div>
206
207 <footer className="annotation-actions">
208 <div className="annotation-actions-left">
209 <button
210 className={`annotation-action ${isLiked ? "liked" : ""}`}
211 onClick={handleLike}
212 >
213 <HeartIcon filled={isLiked} size={16} />
214 {likeCount > 0 && <span>{likeCount}</span>}
215 </button>
216 <ShareMenu
217 uri={data.uri}
218 text={data.title || data.description}
219 handle={data.author?.handle}
220 type="Bookmark"
221 />
222 <button
223 className="annotation-action"
224 onClick={() => {
225 if (!user) {
226 login();
227 return;
228 }
229 if (onAddToCollection) onAddToCollection();
230 }}
231 >
232 <Folder size={16} />
233 <span>Collect</span>
234 </button>
235 </div>
236 </footer>
237 </article>
238 );
239}