Write on the margins of the internet. Powered by the AT Protocol.
at main 185 lines 5.1 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" }}> 109 {(isOwner || onDelete) && ( 110 <button 111 className="annotation-action action-icon-only" 112 onClick={handleDelete} 113 disabled={deleting} 114 title="Delete" 115 > 116 <TrashIcon size={16} /> 117 </button> 118 )} 119 </div> 120 </div> 121 </header> 122 123 <div className="annotation-content"> 124 <a 125 href={data.url} 126 target="_blank" 127 rel="noopener noreferrer" 128 className="bookmark-preview" 129 > 130 <div className="bookmark-preview-content"> 131 <div className="bookmark-preview-site"> 132 <BookmarkIcon size={14} /> 133 <span>{domain}</span> 134 </div> 135 <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 136 {data.description && ( 137 <p className="bookmark-preview-desc">{data.description}</p> 138 )} 139 </div> 140 </a> 141 142 {data.tags?.length > 0 && ( 143 <div className="annotation-tags"> 144 {data.tags.map((tag, i) => ( 145 <span key={i} className="annotation-tag"> 146 #{tag} 147 </span> 148 ))} 149 </div> 150 )} 151 </div> 152 153 <footer className="annotation-actions"> 154 <div className="annotation-actions-left"> 155 <button 156 className={`annotation-action ${isLiked ? "liked" : ""}`} 157 onClick={handleLike} 158 > 159 <HeartIcon filled={isLiked} size={16} /> 160 {likeCount > 0 && <span>{likeCount}</span>} 161 </button> 162 <ShareMenu 163 uri={data.uri} 164 text={data.title || data.description} 165 handle={data.author?.handle} 166 type="Bookmark" 167 /> 168 <button 169 className="annotation-action" 170 onClick={() => { 171 if (!user) { 172 login(); 173 return; 174 } 175 if (onAddToCollection) onAddToCollection(); 176 }} 177 > 178 <Folder size={16} /> 179 <span>Collect</span> 180 </button> 181 </div> 182 </footer> 183 </article> 184 ); 185}