Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at 92fc8b21b443c2bb257fbced7ca5b8ab46037f62 210 lines 6.0 kB view raw
1import { useState, useEffect } from "react"; 2import { useAuth } from "../context/AuthContext"; 3import { 4 normalizeAnnotation, 5 normalizeBookmark, 6 likeAnnotation, 7 unlikeAnnotation, 8 getLikeCount, 9 deleteBookmark, 10} from "../api/client"; 11import { HeartIcon, TrashIcon, BookmarkIcon } from "./Icons"; 12import { Folder } from "lucide-react"; 13import ShareMenu from "./ShareMenu"; 14import UserMeta from "./UserMeta"; 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 let domain = ""; 94 try { 95 if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 96 } catch { 97 /* ignore */ 98 } 99 100 return ( 101 <article className="card annotation-card bookmark-card"> 102 <header className="annotation-header"> 103 <div className="annotation-header-left"> 104 <UserMeta author={data.author} createdAt={data.createdAt} /> 105 </div> 106 107 <div className="annotation-header-right"> 108 <div style={{ display: "flex", gap: "4px", alignItems: "center" }}> 109 {data.uri && data.uri.includes("network.cosmik") && ( 110 <div 111 style={{ 112 display: "flex", 113 alignItems: "center", 114 gap: "4px", 115 fontSize: "0.75rem", 116 color: "var(--text-tertiary)", 117 marginRight: "8px", 118 }} 119 title="Added using Semble" 120 > 121 <span>via Semble</span> 122 <img 123 src="/semble-logo.svg" 124 alt="Semble" 125 style={{ width: "16px", height: "16px" }} 126 /> 127 </div> 128 )} 129 <div style={{ display: "flex", gap: "4px" }}> 130 {((isOwner && 131 !(data.uri && data.uri.includes("network.cosmik"))) || 132 onDelete) && ( 133 <button 134 className="annotation-action action-icon-only" 135 onClick={handleDelete} 136 disabled={deleting} 137 title="Delete" 138 > 139 <TrashIcon size={16} /> 140 </button> 141 )} 142 </div> 143 </div> 144 </div> 145 </header> 146 147 <div className="annotation-content"> 148 <a 149 href={data.url} 150 target="_blank" 151 rel="noopener noreferrer" 152 className="bookmark-preview" 153 > 154 <div className="bookmark-preview-content"> 155 <div className="bookmark-preview-site"> 156 <BookmarkIcon size={14} /> 157 <span>{domain}</span> 158 </div> 159 <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 160 {data.description && ( 161 <p className="bookmark-preview-desc">{data.description}</p> 162 )} 163 </div> 164 </a> 165 166 {data.tags?.length > 0 && ( 167 <div className="annotation-tags"> 168 {data.tags.map((tag, i) => ( 169 <span key={i} className="annotation-tag"> 170 #{tag} 171 </span> 172 ))} 173 </div> 174 )} 175 </div> 176 177 <footer className="annotation-actions"> 178 <div className="annotation-actions-left"> 179 <button 180 className={`annotation-action ${isLiked ? "liked" : ""}`} 181 onClick={handleLike} 182 > 183 <HeartIcon filled={isLiked} size={16} /> 184 {likeCount > 0 && <span>{likeCount}</span>} 185 </button> 186 <ShareMenu 187 uri={data.uri} 188 text={data.title || data.description} 189 handle={data.author?.handle} 190 type="Bookmark" 191 url={data.url} 192 /> 193 <button 194 className="annotation-action" 195 onClick={() => { 196 if (!user) { 197 login(); 198 return; 199 } 200 if (onAddToCollection) onAddToCollection(); 201 }} 202 > 203 <Folder size={16} /> 204 <span>Collect</span> 205 </button> 206 </div> 207 </footer> 208 </article> 209 ); 210}