(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 262 lines 9.5 kB view raw
1import React, { useState, useEffect } from "react"; 2import { useParams } from "react-router-dom"; 3import { getUserTargetItems } from "../../api/client"; 4import type { AnnotationItem, UserProfile } from "../../types"; 5import Card from "../../components/common/Card"; 6import { 7 PenTool, 8 Highlighter, 9 Search, 10 AlertTriangle, 11 ExternalLink, 12} from "lucide-react"; 13import { clsx } from "clsx"; 14import { getAvatarUrl } from "../../api/client"; 15 16export default function UserUrlPage() { 17 const params = useParams(); 18 const handle = params.handle; 19 const urlPath = params["*"]; 20 const targetUrl = urlPath || ""; 21 22 const [profile, setProfile] = useState<UserProfile | null>(null); 23 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 24 const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 25 const [loading, setLoading] = useState(true); 26 const [error, setError] = useState<string | null>(null); 27 const [activeTab, setActiveTab] = useState< 28 "all" | "annotations" | "highlights" 29 >("all"); 30 31 useEffect(() => { 32 async function fetchData() { 33 if (!targetUrl || !handle) { 34 setLoading(false); 35 return; 36 } 37 38 try { 39 setLoading(true); 40 setError(null); 41 42 const profileRes = await fetch( 43 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 44 ); 45 46 let did = handle; 47 if (profileRes.ok) { 48 const profileData = await profileRes.json(); 49 setProfile(profileData); 50 did = profileData.did; 51 } 52 53 const decodedUrl = decodeURIComponent(targetUrl); 54 55 const data = await getUserTargetItems(did, decodedUrl); 56 setAnnotations(data.annotations || []); 57 setHighlights(data.highlights || []); 58 } catch (err) { 59 setError(err instanceof Error ? err.message : "Unknown error"); 60 } finally { 61 setLoading(false); 62 } 63 } 64 fetchData(); 65 }, [handle, targetUrl]); 66 67 const displayName = profile?.displayName || profile?.handle || handle; 68 const displayHandle = 69 profile?.handle || (handle?.startsWith("did:") ? null : handle); 70 const avatarUrl = getAvatarUrl(profile?.did, profile?.avatar); 71 72 const getInitial = () => { 73 return (displayName || displayHandle || "??") 74 ?.substring(0, 2) 75 .toUpperCase(); 76 }; 77 78 const totalItems = annotations.length + highlights.length; 79 const bskyProfileUrl = displayHandle 80 ? `https://bsky.app/profile/${displayHandle}` 81 : `https://bsky.app/profile/${handle}`; 82 83 const renderResults = () => { 84 if (activeTab === "annotations" && annotations.length === 0) { 85 return ( 86 <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 87 <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 88 <PenTool size={24} /> 89 </div> 90 <h3 className="text-lg font-medium text-surface-600"> 91 No annotations 92 </h3> 93 </div> 94 ); 95 } 96 97 if (activeTab === "highlights" && highlights.length === 0) { 98 return ( 99 <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 100 <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 101 <Highlighter size={24} /> 102 </div> 103 <h3 className="text-lg font-medium text-surface-600"> 104 No highlights 105 </h3> 106 </div> 107 ); 108 } 109 110 return ( 111 <div className="space-y-6"> 112 {(activeTab === "all" || activeTab === "annotations") && 113 annotations.map((a) => <Card key={a.uri} item={a} />)} 114 {(activeTab === "all" || activeTab === "highlights") && 115 highlights.map((h) => <Card key={h.uri} item={h} />)} 116 </div> 117 ); 118 }; 119 120 if (!targetUrl) { 121 return ( 122 <div className="max-w-2xl mx-auto py-20 text-center"> 123 <div className="w-16 h-16 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 124 <Search size={32} /> 125 </div> 126 <h3 className="text-xl font-bold text-surface-900 mb-2"> 127 No URL specified 128 </h3> 129 <p className="text-surface-500"> 130 Please provide a URL to view annotations. 131 </p> 132 </div> 133 ); 134 } 135 136 return ( 137 <div className="max-w-3xl mx-auto pb-20"> 138 <header className="flex items-center gap-6 mb-8 p-6 bg-white rounded-2xl border border-surface-200 shadow-sm"> 139 <a 140 href={bskyProfileUrl} 141 target="_blank" 142 rel="noopener noreferrer" 143 className="shrink-0 hover:opacity-80 transition-opacity" 144 > 145 {avatarUrl ? ( 146 <img 147 src={avatarUrl} 148 alt={displayName} 149 className="w-20 h-20 rounded-full object-cover border-4 border-surface-50" 150 /> 151 ) : ( 152 <div className="w-20 h-20 rounded-full bg-surface-100 flex items-center justify-center text-2xl font-bold text-surface-500 border-4 border-surface-50"> 153 {getInitial()} 154 </div> 155 )} 156 </a> 157 <div className="flex-1"> 158 <h1 className="text-2xl font-bold text-surface-900 mb-1"> 159 {displayName} 160 </h1> 161 {displayHandle && ( 162 <a 163 href={bskyProfileUrl} 164 target="_blank" 165 rel="noopener noreferrer" 166 className="text-surface-500 hover:text-primary-600 transition-colors bg-surface-50 hover:bg-primary-50 px-2 py-1 rounded-md text-sm inline-flex items-center gap-1" 167 > 168 @{displayHandle} <ExternalLink size={12} /> 169 </a> 170 )} 171 </div> 172 </header> 173 174 <div className="mb-8 p-4 bg-surface-50 border border-surface-200 rounded-xl flex flex-col sm:flex-row sm:items-center gap-4"> 175 <span className="text-sm font-semibold text-surface-500 uppercase tracking-wide"> 176 Annotations on: 177 </span> 178 <a 179 href={decodeURIComponent(targetUrl)} 180 target="_blank" 181 rel="noopener noreferrer" 182 className="text-primary-600 hover:text-primary-700 hover:underline font-medium truncate flex-1 block" 183 > 184 {decodeURIComponent(targetUrl)} 185 </a> 186 </div> 187 188 {loading && ( 189 <div className="flex justify-center py-12"> 190 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> 191 </div> 192 )} 193 194 {error && ( 195 <div className="mb-8 bg-red-50 text-red-600 p-4 rounded-xl flex items-start gap-3 border border-red-100"> 196 <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 197 <p>{error}</p> 198 </div> 199 )} 200 201 {!loading && !error && totalItems === 0 && ( 202 <div className="text-center py-16 bg-surface-50 rounded-2xl border border-dashed border-surface-200"> 203 <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 204 <PenTool size={24} /> 205 </div> 206 <h3 className="text-lg font-bold text-surface-900 mb-1"> 207 No items found 208 </h3> 209 <p className="text-surface-500"> 210 {displayName} hasn&apos;t annotated this page yet. 211 </p> 212 </div> 213 )} 214 215 {!loading && !error && totalItems > 0 && ( 216 <div className="animate-fade-in"> 217 <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6"> 218 <h2 className="text-xl font-bold text-surface-900"> 219 {totalItems} item{totalItems !== 1 ? "s" : ""} 220 </h2> 221 <div className="flex bg-surface-100 p-1 rounded-xl self-start md:self-auto"> 222 <button 223 className={clsx( 224 "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 225 activeTab === "all" 226 ? "bg-white text-surface-900 shadow-sm" 227 : "text-surface-500 hover:text-surface-700", 228 )} 229 onClick={() => setActiveTab("all")} 230 > 231 All ({totalItems}) 232 </button> 233 <button 234 className={clsx( 235 "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 236 activeTab === "annotations" 237 ? "bg-white text-surface-900 shadow-sm" 238 : "text-surface-500 hover:text-surface-700", 239 )} 240 onClick={() => setActiveTab("annotations")} 241 > 242 Annotations ({annotations.length}) 243 </button> 244 <button 245 className={clsx( 246 "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 247 activeTab === "highlights" 248 ? "bg-white text-surface-900 shadow-sm" 249 : "text-surface-500 hover:text-surface-700", 250 )} 251 onClick={() => setActiveTab("highlights")} 252 > 253 Highlights ({highlights.length}) 254 </button> 255 </div> 256 </div> 257 <div className="space-y-6">{renderResults()}</div> 258 </div> 259 )} 260 </div> 261 ); 262}