Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 283 lines 9.5 kB view raw
1import { 2 AlertTriangle, 3 ExternalLink, 4 Highlighter, 5 Loader2, 6 PenTool, 7 Search, 8} from "lucide-react"; 9import React, { useCallback, useEffect, useState } from "react"; 10import { getUserTargetItems } from "../../api/client"; 11import Card from "../../components/common/Card"; 12import Avatar from "../../components/ui/Avatar"; 13import { EmptyState, Tabs } from "../../components/ui"; 14import type { AnnotationItem, UserProfile } from "../../types"; 15 16interface UserUrlPageProps { 17 handle?: string; 18 urlPath?: string; 19} 20 21export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) { 22 const targetUrl = urlPath || ""; 23 24 const [profile, setProfile] = useState<UserProfile | null>(null); 25 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 26 const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 27 const [loading, setLoading] = useState(true); 28 const [loadingMore, setLoadingMore] = useState(false); 29 const [loadMoreError, setLoadMoreError] = useState<string | null>(null); 30 const [hasMore, setHasMore] = useState(false); 31 const [offset, setOffset] = useState(0); 32 const [error, setError] = useState<string | null>(null); 33 const [activeTab, setActiveTab] = useState< 34 "all" | "annotations" | "highlights" 35 >("all"); 36 37 const LIMIT = 50; 38 const [resolvedDid, setResolvedDid] = useState<string | null>(null); 39 40 useEffect(() => { 41 async function fetchData() { 42 if (!targetUrl || !handle) { 43 setLoading(false); 44 return; 45 } 46 47 try { 48 setLoading(true); 49 setError(null); 50 51 const profileRes = await fetch( 52 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 53 ); 54 55 let did = handle; 56 if (profileRes.ok) { 57 const profileData = await profileRes.json(); 58 setProfile(profileData); 59 did = profileData.did; 60 } 61 62 const decodedUrl = decodeURIComponent(targetUrl); 63 setResolvedDid(did); 64 65 const data = await getUserTargetItems(did, decodedUrl, LIMIT, 0); 66 const fetchedAnnotations = data.annotations || []; 67 const fetchedHighlights = data.highlights || []; 68 setAnnotations(fetchedAnnotations); 69 setHighlights(fetchedHighlights); 70 const totalFetched = 71 fetchedAnnotations.length + fetchedHighlights.length; 72 setHasMore(totalFetched >= LIMIT); 73 setOffset(totalFetched); 74 } catch (err) { 75 setError(err instanceof Error ? err.message : "Unknown error"); 76 } finally { 77 setLoading(false); 78 } 79 } 80 fetchData(); 81 }, [handle, targetUrl]); 82 83 const loadMore = useCallback(async () => { 84 if (!resolvedDid) return; 85 setLoadingMore(true); 86 setLoadMoreError(null); 87 try { 88 const decodedUrl = decodeURIComponent(targetUrl); 89 const data = await getUserTargetItems( 90 resolvedDid, 91 decodedUrl, 92 LIMIT, 93 offset, 94 ); 95 const fetchedAnnotations = data.annotations || []; 96 const fetchedHighlights = data.highlights || []; 97 setAnnotations((prev) => [...prev, ...fetchedAnnotations]); 98 setHighlights((prev) => [...prev, ...fetchedHighlights]); 99 const totalFetched = fetchedAnnotations.length + fetchedHighlights.length; 100 setHasMore(totalFetched >= LIMIT); 101 setOffset((prev) => prev + totalFetched); 102 } catch (err) { 103 console.error("Failed to load more:", err); 104 const msg = err instanceof Error ? err.message : "Something went wrong"; 105 setLoadMoreError(msg); 106 setTimeout(() => setLoadMoreError(null), 5000); 107 } finally { 108 setLoadingMore(false); 109 } 110 }, [resolvedDid, targetUrl, offset]); 111 112 const displayName = profile?.displayName || profile?.handle || handle; 113 const displayHandle = 114 profile?.handle || (handle?.startsWith("did:") ? null : handle); 115 116 const totalItems = annotations.length + highlights.length; 117 const decodedTargetUrl = decodeURIComponent(targetUrl); 118 119 const items = [ 120 ...(activeTab === "all" || activeTab === "annotations" ? annotations : []), 121 ...(activeTab === "all" || activeTab === "highlights" ? highlights : []), 122 ]; 123 124 if (activeTab === "all") { 125 items.sort((a, b) => { 126 const dateA = new Date(a.createdAt).getTime(); 127 const dateB = new Date(b.createdAt).getTime(); 128 return dateB - dateA; 129 }); 130 } 131 132 if (!targetUrl) { 133 return ( 134 <EmptyState 135 icon={<Search size={48} />} 136 title="No URL specified" 137 message="Please provide a URL to view annotations." 138 /> 139 ); 140 } 141 142 return ( 143 <div className="max-w-2xl mx-auto pb-20 animate-fade-in"> 144 <div className="card p-5 mb-4"> 145 <div className="flex items-start gap-4"> 146 <a 147 href={`/profile/${displayHandle || handle}`} 148 className="shrink-0 hover:opacity-80 transition-opacity" 149 > 150 <Avatar 151 did={profile?.did} 152 avatar={profile?.avatar} 153 size="lg" 154 className="ring-4 ring-surface-100 dark:ring-surface-800" 155 /> 156 </a> 157 <div className="flex-1 min-w-0"> 158 <a 159 href={`/profile/${displayHandle || handle}`} 160 className="hover:underline" 161 > 162 <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate"> 163 {displayName} 164 </h1> 165 </a> 166 {displayHandle && ( 167 <p className="text-surface-500 dark:text-surface-400"> 168 @{displayHandle} 169 </p> 170 )} 171 </div> 172 </div> 173 174 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700"> 175 <div className="flex items-center gap-2 text-sm"> 176 <span className="text-surface-400 dark:text-surface-500 font-medium shrink-0"> 177 on 178 </span> 179 <a 180 href={decodedTargetUrl} 181 target="_blank" 182 rel="noopener noreferrer" 183 className="text-primary-600 dark:text-primary-400 hover:underline truncate flex items-center gap-1" 184 > 185 <span className="truncate">{decodedTargetUrl}</span> 186 <ExternalLink size={12} className="shrink-0" /> 187 </a> 188 </div> 189 </div> 190 </div> 191 192 {loading && ( 193 <div className="flex flex-col items-center justify-center py-20"> 194 <Loader2 195 className="animate-spin text-primary-600 dark:text-primary-400 mb-4" 196 size={32} 197 /> 198 <p className="text-surface-500 dark:text-surface-400"> 199 Loading annotations... 200 </p> 201 </div> 202 )} 203 204 {error && ( 205 <div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-xl flex items-start gap-3 border border-red-100 dark:border-red-900/30 mb-6"> 206 <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 207 <p>{error}</p> 208 </div> 209 )} 210 211 {!loading && !error && totalItems === 0 && ( 212 <EmptyState 213 icon={<PenTool size={32} />} 214 title="No items found" 215 message={`${displayName} hasn't annotated this page yet.`} 216 /> 217 )} 218 219 {!loading && !error && totalItems > 0 && ( 220 <div> 221 <div className="mb-6"> 222 <Tabs 223 tabs={[ 224 { id: "all", label: "All" }, 225 { id: "annotations", label: "Annotations" }, 226 { id: "highlights", label: "Highlights" }, 227 ]} 228 activeTab={activeTab} 229 onChange={(id: string) => 230 setActiveTab(id as "all" | "annotations" | "highlights") 231 } 232 /> 233 </div> 234 235 <div className="space-y-4"> 236 {activeTab === "annotations" && annotations.length === 0 && ( 237 <EmptyState 238 icon={<PenTool size={32} />} 239 title="No annotations" 240 message={`${displayName} hasn't annotated this page yet.`} 241 /> 242 )} 243 {activeTab === "highlights" && highlights.length === 0 && ( 244 <EmptyState 245 icon={<Highlighter size={32} />} 246 title="No highlights" 247 message={`${displayName} hasn't highlighted this page yet.`} 248 /> 249 )} 250 251 {items.map((item) => ( 252 <Card key={item.uri} item={item} /> 253 ))} 254 </div> 255 256 {hasMore && ( 257 <div className="flex flex-col items-center gap-2 py-6"> 258 {loadMoreError && ( 259 <p className="text-sm text-red-500 dark:text-red-400"> 260 Failed to load more: {loadMoreError} 261 </p> 262 )} 263 <button 264 onClick={loadMore} 265 disabled={loadingMore} 266 className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50" 267 > 268 {loadingMore ? ( 269 <> 270 <Loader2 size={16} className="animate-spin" /> 271 Loading... 272 </> 273 ) : ( 274 "Load more" 275 )} 276 </button> 277 </div> 278 )} 279 </div> 280 )} 281 </div> 282 ); 283}