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