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