(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 frontend-rewrite 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}