(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 253 lines 7.5 kB view raw
1import { useState, useEffect } from "react"; 2import { useParams, Link, useLocation } from "react-router-dom"; 3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4import BookmarkCard from "../components/BookmarkCard"; 5import ReplyList from "../components/ReplyList"; 6import { 7 getAnnotation, 8 getReplies, 9 createReply, 10 deleteReply, 11 resolveHandle, 12 normalizeAnnotation, 13} from "../api/client"; 14import { useAuth } from "../context/AuthContext"; 15import { MessageSquare } from "lucide-react"; 16 17export default function AnnotationDetail() { 18 const { uri, did, rkey, handle, type } = useParams(); 19 const location = useLocation(); 20 const { isAuthenticated, user } = useAuth(); 21 const [annotation, setAnnotation] = useState(null); 22 const [replies, setReplies] = useState([]); 23 const [loading, setLoading] = useState(true); 24 const [error, setError] = useState(null); 25 26 const [replyText, setReplyText] = useState(""); 27 const [posting, setPosting] = useState(false); 28 const [replyingTo, setReplyingTo] = useState(null); 29 30 const [targetUri, setTargetUri] = useState(uri); 31 32 useEffect(() => { 33 async function resolve() { 34 if (uri) { 35 setTargetUri(uri); 36 return; 37 } 38 39 if (handle && rkey) { 40 let collection = "at.margin.annotation"; 41 if (type === "highlight") collection = "at.margin.highlight"; 42 if (type === "bookmark") collection = "at.margin.bookmark"; 43 44 try { 45 const resolvedDid = await resolveHandle(handle); 46 if (resolvedDid) { 47 setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); 48 } 49 } catch (e) { 50 console.error("Failed to resolve handle:", e); 51 } 52 } else if (did && rkey) { 53 setTargetUri(`at://${did}/at.margin.annotation/${rkey}`); 54 } else { 55 const pathParts = location.pathname.split("/"); 56 const atIndex = pathParts.indexOf("at"); 57 if ( 58 atIndex !== -1 && 59 pathParts[atIndex + 1] && 60 pathParts[atIndex + 2] 61 ) { 62 setTargetUri( 63 `at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`, 64 ); 65 } 66 } 67 } 68 resolve(); 69 }, [uri, did, rkey, handle, type, location.pathname]); 70 71 const refreshReplies = async () => { 72 if (!targetUri) return; 73 const repliesData = await getReplies(targetUri); 74 setReplies(repliesData.items || []); 75 }; 76 77 useEffect(() => { 78 async function fetchData() { 79 if (!targetUri) return; 80 81 try { 82 setLoading(true); 83 const [annData, repliesData] = await Promise.all([ 84 getAnnotation(targetUri), 85 getReplies(targetUri).catch(() => ({ items: [] })), 86 ]); 87 setAnnotation(normalizeAnnotation(annData)); 88 setReplies(repliesData.items || []); 89 } catch (err) { 90 setError(err.message); 91 } finally { 92 setLoading(false); 93 } 94 } 95 fetchData(); 96 }, [targetUri]); 97 98 const handleReply = async (e) => { 99 if (e) e.preventDefault(); 100 if (!replyText.trim()) return; 101 102 try { 103 setPosting(true); 104 const parentUri = replyingTo 105 ? replyingTo.id || replyingTo.uri 106 : targetUri; 107 const parentCid = replyingTo 108 ? replyingTo.cid || "" 109 : annotation?.cid || ""; 110 111 await createReply({ 112 parentUri, 113 parentCid, 114 rootUri: targetUri, 115 rootCid: annotation?.cid || "", 116 text: replyText, 117 }); 118 setReplyText(""); 119 setReplyingTo(null); 120 await refreshReplies(); 121 } catch (err) { 122 alert("Failed to post reply: " + err.message); 123 } finally { 124 setPosting(false); 125 } 126 }; 127 128 const handleDeleteReply = async (reply) => { 129 if (!confirm("Delete this reply?")) return; 130 try { 131 await deleteReply(reply.id || reply.uri); 132 await refreshReplies(); 133 } catch (err) { 134 alert("Failed to delete: " + err.message); 135 } 136 }; 137 138 if (loading) { 139 return ( 140 <div className="annotation-detail-page"> 141 <div className="card"> 142 <div className="skeleton skeleton-text" style={{ width: "40%" }} /> 143 <div className="skeleton skeleton-text" /> 144 <div className="skeleton skeleton-text" style={{ width: "60%" }} /> 145 </div> 146 </div> 147 ); 148 } 149 150 if (error || !annotation) { 151 return ( 152 <div className="annotation-detail-page"> 153 <div className="empty-state"> 154 <div className="empty-state-icon"></div> 155 <h3 className="empty-state-title">Annotation not found</h3> 156 <p className="empty-state-text"> 157 {error || "This annotation may have been deleted."} 158 </p> 159 <Link 160 to="/" 161 className="btn btn-primary" 162 style={{ marginTop: "16px" }} 163 > 164 Back to Feed 165 </Link> 166 </div> 167 </div> 168 ); 169 } 170 171 return ( 172 <div className="annotation-detail-page"> 173 <div className="annotation-detail-header"> 174 <Link to="/" className="back-link"> 175 Back to Feed 176 </Link> 177 </div> 178 179 {annotation.type === "Highlight" ? ( 180 <HighlightCard 181 highlight={annotation} 182 onDelete={() => (window.location.href = "/")} 183 /> 184 ) : annotation.type === "Bookmark" ? ( 185 <BookmarkCard 186 bookmark={annotation} 187 onDelete={() => (window.location.href = "/")} 188 /> 189 ) : ( 190 <AnnotationCard annotation={annotation} /> 191 )} 192 193 {annotation.type !== "Bookmark" && annotation.type !== "Highlight" && ( 194 <div className="replies-section"> 195 <h3 className="replies-title"> 196 <MessageSquare size={18} /> 197 Replies ({replies.length}) 198 </h3> 199 200 {isAuthenticated && ( 201 <div className="reply-form card"> 202 {replyingTo && ( 203 <div className="replying-to-banner"> 204 <span> 205 Replying to @ 206 {(replyingTo.creator || replyingTo.author)?.handle || 207 "unknown"} 208 </span> 209 <button 210 onClick={() => setReplyingTo(null)} 211 className="cancel-reply" 212 > 213 × 214 </button> 215 </div> 216 )} 217 <textarea 218 value={replyText} 219 onChange={(e) => setReplyText(e.target.value)} 220 placeholder={ 221 replyingTo 222 ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 223 : "Write a reply..." 224 } 225 className="reply-input" 226 rows={3} 227 disabled={posting} 228 /> 229 <div className="reply-form-actions"> 230 <button 231 className="btn btn-primary" 232 disabled={posting || !replyText.trim()} 233 onClick={() => handleReply()} 234 > 235 {posting ? "Posting..." : "Reply"} 236 </button> 237 </div> 238 </div> 239 )} 240 241 <ReplyList 242 replies={replies} 243 rootUri={targetUri} 244 user={user} 245 onReply={(reply) => setReplyingTo(reply)} 246 onDelete={handleDeleteReply} 247 isInline={false} 248 /> 249 </div> 250 )} 251 </div> 252 ); 253}