1import { useState, useEffect } from "react";
2import { useAuth } from "../context/AuthContext";
3import { Link } from "react-router-dom";
4import {
5 normalizeAnnotation,
6 likeAnnotation,
7 unlikeAnnotation,
8 getLikeCount,
9 deleteBookmark,
10} from "../api/client";
11import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons";
12import { Folder } from "lucide-react";
13import AddToCollectionModal from "./AddToCollectionModal";
14import ShareMenu from "./ShareMenu";
15
16export default function BookmarkCard({ bookmark, annotation, onDelete }) {
17 const { user, login } = useAuth();
18 const data = normalizeAnnotation(bookmark || annotation);
19
20 const [likeCount, setLikeCount] = useState(0);
21 const [isLiked, setIsLiked] = useState(false);
22 const [deleting, setDeleting] = useState(false);
23 const [showAddToCollection, setShowAddToCollection] = useState(false);
24
25 const isOwner = user?.did && data.author?.did === user.did;
26
27 useEffect(() => {
28 let mounted = true;
29 async function fetchData() {
30 try {
31 const likeRes = await getLikeCount(data.uri);
32 if (mounted) {
33 if (likeRes.count !== undefined) setLikeCount(likeRes.count);
34 if (likeRes.liked !== undefined) setIsLiked(likeRes.liked);
35 }
36 } catch (err) {
37 console.error("Failed to fetch data:", err);
38 }
39 }
40 if (data.uri) fetchData();
41 return () => {
42 mounted = false;
43 };
44 }, [data.uri]);
45
46 const handleLike = async () => {
47 if (!user) {
48 login();
49 return;
50 }
51 try {
52 if (isLiked) {
53 setIsLiked(false);
54 setLikeCount((prev) => Math.max(0, prev - 1));
55 await unlikeAnnotation(data.uri);
56 } else {
57 setIsLiked(true);
58 setLikeCount((prev) => prev + 1);
59 const cid = data.cid || "";
60 if (data.uri && cid) await likeAnnotation(data.uri, cid);
61 }
62 } catch (err) {
63 setIsLiked(!isLiked);
64 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1));
65 }
66 };
67
68 const handleDelete = async () => {
69 if (!confirm("Delete this bookmark?")) return;
70 try {
71 setDeleting(true);
72 const parts = data.uri.split("/");
73 const rkey = parts[parts.length - 1];
74 await deleteBookmark(rkey);
75 if (onDelete) onDelete(data.uri);
76 else window.location.reload();
77 } catch (err) {
78 alert("Failed to delete: " + err.message);
79 } finally {
80 setDeleting(false);
81 }
82 };
83
84 const handleShare = async () => {
85 const uriParts = data.uri.split("/");
86 const did = uriParts[2];
87 const rkey = uriParts[uriParts.length - 1];
88 const shareUrl = `${window.location.origin}/at/${did}/${rkey}`;
89 if (navigator.share) {
90 try {
91 await navigator.share({ title: "Bookmark", url: shareUrl });
92 } catch {}
93 } else {
94 try {
95 await navigator.clipboard.writeText(shareUrl);
96 alert("Link copied!");
97 } catch {
98 prompt("Copy:", shareUrl);
99 }
100 }
101 };
102
103 const formatDate = (dateString) => {
104 if (!dateString) return "";
105 const date = new Date(dateString);
106 const now = new Date();
107 const diff = now - date;
108 const minutes = Math.floor(diff / 60000);
109 const hours = Math.floor(diff / 3600000);
110 const days = Math.floor(diff / 86400000);
111 if (minutes < 1) return "just now";
112 if (minutes < 60) return `${minutes}m`;
113 if (hours < 24) return `${hours}h`;
114 if (days < 7) return `${days}d`;
115 return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
116 };
117
118 let domain = "";
119 try {
120 if (data.url) domain = new URL(data.url).hostname.replace("www.", "");
121 } catch {}
122
123 const authorDisplayName = data.author?.displayName || data.author?.handle;
124 const authorHandle = data.author?.handle;
125 const authorAvatar = data.author?.avatar;
126 const authorDid = data.author?.did;
127 const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null;
128
129 return (
130 <article className="card bookmark-card">
131 {}
132 <header className="annotation-header">
133 <Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
134 <div className="annotation-avatar">
135 {authorAvatar ? (
136 <img src={authorAvatar} alt={authorDisplayName} />
137 ) : (
138 <span>
139 {(authorDisplayName || authorHandle || "??")
140 ?.substring(0, 2)
141 .toUpperCase()}
142 </span>
143 )}
144 </div>
145 </Link>
146 <div className="annotation-meta">
147 <div className="annotation-author-row">
148 <Link
149 to={marginProfileUrl || "#"}
150 className="annotation-author-link"
151 >
152 <span className="annotation-author">{authorDisplayName}</span>
153 </Link>
154 {authorHandle && (
155 <a
156 href={`https://bsky.app/profile/${authorHandle}`}
157 target="_blank"
158 rel="noopener noreferrer"
159 className="annotation-handle"
160 >
161 @{authorHandle} <ExternalLinkIcon size={12} />
162 </a>
163 )}
164 </div>
165 <div className="annotation-time">{formatDate(data.createdAt)}</div>
166 </div>
167 <div className="action-buttons">
168 {isOwner && (
169 <button
170 className="annotation-delete"
171 onClick={handleDelete}
172 disabled={deleting}
173 title="Delete"
174 >
175 <TrashIcon size={16} />
176 </button>
177 )}
178 </div>
179 </header>
180
181 {}
182 <a
183 href={data.url}
184 target="_blank"
185 rel="noopener noreferrer"
186 className="bookmark-preview"
187 >
188 <div className="bookmark-preview-content">
189 <div className="bookmark-preview-site">
190 <BookmarkIcon size={14} />
191 <span>{domain}</span>
192 </div>
193 <h3 className="bookmark-preview-title">{data.title || data.url}</h3>
194 {data.description && (
195 <p className="bookmark-preview-desc">{data.description}</p>
196 )}
197 </div>
198 <div className="bookmark-preview-arrow">
199 <ExternalLinkIcon size={18} />
200 </div>
201 </a>
202
203 {}
204 {data.tags?.length > 0 && (
205 <div className="annotation-tags">
206 {data.tags.map((tag, i) => (
207 <span key={i} className="annotation-tag">
208 #{tag}
209 </span>
210 ))}
211 </div>
212 )}
213
214 {}
215 <footer className="annotation-actions">
216 <button
217 className={`annotation-action ${isLiked ? "liked" : ""}`}
218 onClick={handleLike}
219 >
220 <HeartIcon filled={isLiked} size={16} />
221 {likeCount > 0 && <span>{likeCount}</span>}
222 </button>
223 <ShareMenu uri={data.uri} text={data.title || data.description} />
224 <button
225 className="annotation-action"
226 onClick={() => {
227 if (!user) {
228 login();
229 return;
230 }
231 setShowAddToCollection(true);
232 }}
233 >
234 <Folder size={16} />
235 <span>Collect</span>
236 </button>
237 </footer>
238
239 {showAddToCollection && (
240 <AddToCollectionModal
241 isOpen={showAddToCollection}
242 annotationUri={data.uri}
243 onClose={() => setShowAddToCollection(false)}
244 />
245 )}
246 </article>
247 );
248}