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