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