Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 7.1 kB view raw
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}