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