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