(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at ui-refactor 190 lines 5.3 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 } from "./Icons"; 12import { Folder, ExternalLink } 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 const isSemble = data.uri?.includes("network.cosmik"); 32 33 let domain = ""; 34 try { 35 if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 36 } catch { 37 /* ignore */ 38 } 39 40 useEffect(() => { 41 let mounted = true; 42 async function fetchData() { 43 try { 44 const likeRes = await getLikeCount(data.uri); 45 if (mounted) { 46 if (likeRes.count !== undefined) setLikeCount(likeRes.count); 47 if (likeRes.liked !== undefined) setIsLiked(likeRes.liked); 48 } 49 } catch { 50 /* ignore */ 51 } 52 } 53 if (data.uri) fetchData(); 54 return () => { 55 mounted = false; 56 }; 57 }, [data.uri]); 58 59 const handleLike = async () => { 60 if (!user) { 61 login(); 62 return; 63 } 64 try { 65 if (isLiked) { 66 setIsLiked(false); 67 setLikeCount((prev) => Math.max(0, prev - 1)); 68 await unlikeAnnotation(data.uri); 69 } else { 70 setIsLiked(true); 71 setLikeCount((prev) => prev + 1); 72 const cid = data.cid || ""; 73 if (data.uri && cid) await likeAnnotation(data.uri, cid); 74 } 75 } catch { 76 setIsLiked(!isLiked); 77 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 78 } 79 }; 80 81 const handleDelete = async () => { 82 if (onDelete) { 83 onDelete(data.uri); 84 return; 85 } 86 if (!confirm("Delete this bookmark?")) return; 87 try { 88 setDeleting(true); 89 const parts = data.uri.split("/"); 90 const rkey = parts[parts.length - 1]; 91 await deleteBookmark(rkey); 92 window.location.reload(); 93 } catch (err) { 94 alert("Failed to delete: " + err.message); 95 } finally { 96 setDeleting(false); 97 } 98 }; 99 100 const handleCollect = () => { 101 if (!user) { 102 login(); 103 return; 104 } 105 if (onAddToCollection) onAddToCollection(); 106 }; 107 108 return ( 109 <article className="card annotation-card bookmark-card"> 110 <header className="annotation-header"> 111 <div className="annotation-header-left"> 112 <UserMeta author={data.author} createdAt={data.createdAt} /> 113 </div> 114 <div className="annotation-header-right"> 115 {isSemble && ( 116 <div className="semble-badge" title="Added using Semble"> 117 <span>via Semble</span> 118 <img src="/semble-logo.svg" alt="Semble" /> 119 </div> 120 )} 121 {((isOwner && !isSemble) || onDelete) && ( 122 <button 123 className="annotation-action action-icon-only" 124 onClick={handleDelete} 125 disabled={deleting} 126 title="Delete" 127 > 128 <TrashIcon size={16} /> 129 </button> 130 )} 131 </div> 132 </header> 133 134 <div className="annotation-content"> 135 <a 136 href={data.url} 137 target="_blank" 138 rel="noopener noreferrer" 139 className="bookmark-preview" 140 > 141 <div className="bookmark-preview-content"> 142 <div className="bookmark-preview-site"> 143 <ExternalLink size={12} /> 144 <span>{domain}</span> 145 </div> 146 <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 147 {data.description && ( 148 <p className="bookmark-preview-desc">{data.description}</p> 149 )} 150 </div> 151 </a> 152 153 {data.tags?.length > 0 && ( 154 <div className="annotation-tags"> 155 {data.tags.map((tag, i) => ( 156 <span key={i} className="annotation-tag"> 157 #{tag} 158 </span> 159 ))} 160 </div> 161 )} 162 </div> 163 164 <footer className="annotation-actions"> 165 <div className="annotation-actions-left"> 166 <button 167 className={`annotation-action ${isLiked ? "liked" : ""}`} 168 onClick={handleLike} 169 > 170 <HeartIcon filled={isLiked} size={16} /> 171 {likeCount > 0 && <span>{likeCount}</span>} 172 </button> 173 174 <ShareMenu 175 uri={data.uri} 176 text={data.title || data.description} 177 handle={data.author?.handle} 178 type="Bookmark" 179 url={data.url} 180 /> 181 182 <button className="annotation-action" onClick={handleCollect}> 183 <Folder size={16} /> 184 <span>Collect</span> 185 </button> 186 </div> 187 </footer> 188 </article> 189 ); 190}