Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at v0.1.13 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, 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}