Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 306 lines 11 kB view raw
1import React, { useEffect, useState } from "react"; 2import { useParams, Link, useLocation, useNavigate } from "react-router-dom"; 3import { useStore } from "@nanostores/react"; 4import { $user } from "../../store/auth"; 5import { 6 getAnnotation, 7 getReplies, 8 resolveHandle, 9 createReply, 10 deleteReply, 11} from "../../api/client"; 12import type { AnnotationItem } from "../../types"; 13import Card from "../../components/common/Card"; 14import ReplyList from "../../components/feed/ReplyList"; 15import { 16 Loader2, 17 MessageSquare, 18 ArrowLeft, 19 X, 20 AlertTriangle, 21} from "lucide-react"; 22import { getAvatarUrl } from "../../api/client"; 23 24export default function AnnotationDetail() { 25 const { uri, did, rkey, handle, type } = useParams(); 26 const location = useLocation(); 27 const navigate = useNavigate(); 28 const user = useStore($user); 29 30 const [annotation, setAnnotation] = useState<AnnotationItem | null>(null); 31 const [replies, setReplies] = useState<AnnotationItem[]>([]); 32 const [loading, setLoading] = useState(true); 33 const [error, setError] = useState<string | null>(null); 34 35 const [replyText, setReplyText] = useState(""); 36 const [posting, setPosting] = useState(false); 37 const [replyingTo, setReplyingTo] = useState<AnnotationItem | null>(null); 38 39 const [targetUri, setTargetUri] = useState<string | null>(uri || null); 40 41 useEffect(() => { 42 async function resolve() { 43 if (uri) { 44 setTargetUri(decodeURIComponent(uri)); 45 return; 46 } 47 48 if (handle && rkey) { 49 let collection = "at.margin.annotation"; 50 if (type === "highlight" || location.pathname.includes("/highlight/")) 51 collection = "at.margin.highlight"; 52 if (type === "bookmark" || location.pathname.includes("/bookmark/")) 53 collection = "at.margin.bookmark"; 54 55 try { 56 const resolvedDid = await resolveHandle(handle); 57 if (resolvedDid) { 58 setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); 59 } else { 60 throw new Error("Could not resolve handle"); 61 } 62 } catch (e) { 63 setError( 64 "Failed to resolve handle: " + 65 (e instanceof Error ? e.message : "Unknown error"), 66 ); 67 setLoading(false); 68 } 69 } else if (did && rkey) { 70 setTargetUri(`at://${did}/at.margin.annotation/${rkey}`); 71 } else { 72 const pathParts = (location.pathname || "").split("/"); 73 const atIndex = pathParts.indexOf("at"); 74 if ( 75 atIndex !== -1 && 76 pathParts[atIndex + 1] && 77 pathParts[atIndex + 2] 78 ) { 79 setTargetUri( 80 `at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`, 81 ); 82 } 83 } 84 } 85 resolve(); 86 }, [uri, did, rkey, handle, type, location.pathname]); 87 88 const refreshReplies = async () => { 89 if (!targetUri) return; 90 const repliesData = await getReplies(targetUri); 91 setReplies(repliesData.items || []); 92 }; 93 94 useEffect(() => { 95 async function fetchData() { 96 if (!targetUri) return; 97 98 try { 99 setLoading(true); 100 const [annData, repliesData] = await Promise.all([ 101 getAnnotation(targetUri), 102 getReplies(targetUri).catch(() => ({ 103 items: [] as AnnotationItem[], 104 })), 105 ]); 106 107 if (!annData) { 108 setError("Annotation not found"); 109 } else { 110 setAnnotation(annData); 111 setReplies(repliesData.items || []); 112 } 113 } catch (err) { 114 setError(err instanceof Error ? err.message : "Unknown error"); 115 } finally { 116 setLoading(false); 117 } 118 } 119 fetchData(); 120 }, [targetUri]); 121 122 const handleReply = async (e?: React.FormEvent) => { 123 if (e) e.preventDefault(); 124 if (!replyText.trim() || !annotation || !targetUri) return; 125 126 try { 127 setPosting(true); 128 const parentUri = replyingTo 129 ? replyingTo.uri || replyingTo.id 130 : targetUri; 131 const parentCid = replyingTo ? replyingTo.cid : annotation.cid; 132 133 if (!parentUri || !parentCid || !annotation.cid) 134 throw new Error("Missing parent info"); 135 136 await createReply( 137 parentUri, 138 parentCid, 139 targetUri, 140 annotation.cid, 141 replyText, 142 ); 143 144 setReplyText(""); 145 setReplyingTo(null); 146 await refreshReplies(); 147 } catch (err) { 148 alert( 149 "Failed to post reply: " + 150 (err instanceof Error ? err.message : "Unknown error"), 151 ); 152 } finally { 153 setPosting(false); 154 } 155 }; 156 157 const handleDeleteReply = async (reply: AnnotationItem) => { 158 if (!window.confirm("Delete this reply?")) return; 159 try { 160 await deleteReply(reply.uri || reply.id!); 161 await refreshReplies(); 162 } catch (err) { 163 alert( 164 "Failed to delete: " + 165 (err instanceof Error ? err.message : "Unknown error"), 166 ); 167 } 168 }; 169 170 if (loading) { 171 return ( 172 <div className="flex justify-center py-20"> 173 <Loader2 174 className="animate-spin text-primary-600 dark:text-primary-400" 175 size={32} 176 /> 177 </div> 178 ); 179 } 180 181 if (error || !annotation) { 182 return ( 183 <div className="max-w-md mx-auto py-12 px-4 text-center"> 184 <div className="w-14 h-14 bg-surface-100 dark:bg-surface-800 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400 dark:text-surface-500"> 185 <AlertTriangle size={28} /> 186 </div> 187 <h3 className="text-xl font-bold text-surface-900 dark:text-white mb-2"> 188 Not found 189 </h3> 190 <p className="text-surface-500 dark:text-surface-400 text-sm mb-6"> 191 {error || "This may have been deleted."} 192 </p> 193 <Link 194 to="/home" 195 className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" 196 > 197 Back to Feed 198 </Link> 199 </div> 200 ); 201 } 202 203 return ( 204 <div className="max-w-2xl mx-auto pb-20"> 205 <div className="mb-4"> 206 <Link 207 to="/home" 208 className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white transition-colors" 209 > 210 <ArrowLeft size={16} /> 211 Back 212 </Link> 213 </div> 214 215 <Card item={annotation} onDelete={() => navigate("/home")} /> 216 217 {annotation.type !== "Bookmark" && 218 annotation.type !== "Highlight" && 219 !annotation.motivation?.includes("bookmark") && 220 !annotation.motivation?.includes("highlight") && ( 221 <div className="mt-6"> 222 <h3 className="flex items-center gap-2 text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 223 <MessageSquare size={16} /> 224 Replies ({replies.length}) 225 </h3> 226 227 {user ? ( 228 <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-4 mb-4"> 229 {replyingTo && ( 230 <div className="flex items-center justify-between bg-surface-50 dark:bg-surface-800 px-3 py-2 rounded-lg mb-3 border border-surface-200 dark:border-surface-700"> 231 <span className="text-sm text-surface-600 dark:text-surface-300"> 232 Replying to{" "} 233 <span className="font-medium text-surface-900 dark:text-white"> 234 @ 235 {(replyingTo.author || replyingTo.creator)?.handle || 236 "unknown"} 237 </span> 238 </span> 239 <button 240 onClick={() => setReplyingTo(null)} 241 className="text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white p-1" 242 > 243 <X size={14} /> 244 </button> 245 </div> 246 )} 247 <div className="flex gap-3"> 248 {getAvatarUrl(user.did, user.avatar) ? ( 249 <img 250 src={getAvatarUrl(user.did, user.avatar)} 251 alt="" 252 className="w-8 h-8 rounded-full object-cover bg-surface-100 dark:bg-surface-800" 253 /> 254 ) : ( 255 <div className="w-8 h-8 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xs font-bold text-surface-400 dark:text-surface-500"> 256 {user.handle?.[0]?.toUpperCase()} 257 </div> 258 )} 259 <div className="flex-1"> 260 <textarea 261 value={replyText} 262 onChange={(e) => setReplyText(e.target.value)} 263 placeholder="Write a reply..." 264 className="w-full p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none resize-none min-h-[80px]" 265 rows={2} 266 disabled={posting} 267 /> 268 <div className="flex justify-end mt-2 pt-2 border-t border-surface-100 dark:border-surface-800"> 269 <button 270 className="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50" 271 disabled={posting || !replyText.trim()} 272 onClick={() => handleReply()} 273 > 274 {posting ? "..." : "Reply"} 275 </button> 276 </div> 277 </div> 278 </div> 279 </div> 280 ) : ( 281 <div className="bg-surface-50 dark:bg-surface-800/50 rounded-xl p-5 text-center mb-4 border border-dashed border-surface-200 dark:border-surface-700"> 282 <p className="text-surface-500 dark:text-surface-400 text-sm mb-2"> 283 Sign in to reply 284 </p> 285 <Link 286 to="/login" 287 className="text-primary-600 dark:text-primary-400 font-medium hover:underline text-sm" 288 > 289 Log in 290 </Link> 291 </div> 292 )} 293 294 <ReplyList 295 replies={replies} 296 rootUri={targetUri || ""} 297 user={user} 298 onReply={(reply) => setReplyingTo(reply)} 299 onDelete={handleDeleteReply} 300 isInline={false} 301 /> 302 </div> 303 )} 304 </div> 305 ); 306}