Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 239 lines 7.2 kB view raw
1import { useState, useEffect } from "react"; 2import { useParams } from "react-router-dom"; 3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4import { getUserTargetItems } from "../api/client"; 5import { 6 PenIcon, 7 HighlightIcon, 8 SearchIcon, 9 BlueskyIcon, 10} from "../components/Icons"; 11 12export default function UserUrl() { 13 const { handle, "*": urlPath } = useParams(); 14 const targetUrl = urlPath || ""; 15 16 const [profile, setProfile] = useState(null); 17 const [annotations, setAnnotations] = useState([]); 18 const [highlights, setHighlights] = useState([]); 19 const [loading, setLoading] = useState(true); 20 const [error, setError] = useState(null); 21 const [activeTab, setActiveTab] = useState("all"); 22 23 useEffect(() => { 24 async function fetchData() { 25 if (!targetUrl) { 26 setLoading(false); 27 return; 28 } 29 30 try { 31 setLoading(true); 32 setError(null); 33 34 const profileRes = await fetch( 35 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 36 ); 37 let did = handle; 38 if (profileRes.ok) { 39 const profileData = await profileRes.json(); 40 setProfile(profileData); 41 did = profileData.did; 42 } 43 44 const data = await getUserTargetItems(did, targetUrl); 45 setAnnotations(data.annotations || []); 46 setHighlights(data.highlights || []); 47 } catch (err) { 48 setError(err.message); 49 } finally { 50 setLoading(false); 51 } 52 } 53 fetchData(); 54 }, [handle, targetUrl]); 55 56 const displayName = profile?.displayName || profile?.handle || handle; 57 const displayHandle = 58 profile?.handle || (handle?.startsWith("did:") ? null : handle); 59 const avatarUrl = profile?.avatar; 60 61 const getInitial = () => { 62 return (displayName || displayHandle || "??") 63 ?.substring(0, 2) 64 .toUpperCase(); 65 }; 66 67 const totalItems = annotations.length + highlights.length; 68 const bskyProfileUrl = displayHandle 69 ? `https://bsky.app/profile/${displayHandle}` 70 : `https://bsky.app/profile/${handle}`; 71 72 const renderResults = () => { 73 if (activeTab === "annotations" && annotations.length === 0) { 74 return ( 75 <div className="empty-state"> 76 <div className="empty-state-icon"> 77 <PenIcon size={32} /> 78 </div> 79 <h3 className="empty-state-title">No annotations</h3> 80 </div> 81 ); 82 } 83 84 if (activeTab === "highlights" && highlights.length === 0) { 85 return ( 86 <div className="empty-state"> 87 <div className="empty-state-icon"> 88 <HighlightIcon size={32} /> 89 </div> 90 <h3 className="empty-state-title">No highlights</h3> 91 </div> 92 ); 93 } 94 95 return ( 96 <> 97 {(activeTab === "all" || activeTab === "annotations") && 98 annotations.map((a) => <AnnotationCard key={a.uri} annotation={a} />)} 99 {(activeTab === "all" || activeTab === "highlights") && 100 highlights.map((h) => <HighlightCard key={h.uri} highlight={h} />)} 101 </> 102 ); 103 }; 104 105 if (!targetUrl) { 106 return ( 107 <div className="user-url-page"> 108 <div className="empty-state"> 109 <div className="empty-state-icon"> 110 <SearchIcon size={32} /> 111 </div> 112 <h3 className="empty-state-title">No URL specified</h3> 113 <p className="empty-state-text"> 114 Please provide a URL to view annotations. 115 </p> 116 </div> 117 </div> 118 ); 119 } 120 121 return ( 122 <div className="user-url-page"> 123 <header className="profile-header"> 124 <a 125 href={bskyProfileUrl} 126 target="_blank" 127 rel="noopener noreferrer" 128 className="profile-avatar-link" 129 > 130 <div className="profile-avatar"> 131 {avatarUrl ? ( 132 <img src={avatarUrl} alt={displayName} /> 133 ) : ( 134 <span>{getInitial()}</span> 135 )} 136 </div> 137 </a> 138 <div className="profile-info"> 139 <h1 className="profile-name">{displayName}</h1> 140 {displayHandle && ( 141 <a 142 href={bskyProfileUrl} 143 target="_blank" 144 rel="noopener noreferrer" 145 className="profile-bluesky-link" 146 > 147 <BlueskyIcon size={16} />@{displayHandle} 148 </a> 149 )} 150 </div> 151 </header> 152 153 <div className="url-target-info"> 154 <span className="url-target-label">Annotations on:</span> 155 <a 156 href={targetUrl} 157 target="_blank" 158 rel="noopener noreferrer" 159 className="url-target-link" 160 > 161 {targetUrl} 162 </a> 163 </div> 164 165 {loading && ( 166 <div className="feed-container"> 167 <div className="feed"> 168 {[1, 2, 3].map((i) => ( 169 <div key={i} className="card"> 170 <div 171 className="skeleton skeleton-text" 172 style={{ width: "40%" }} 173 /> 174 <div className="skeleton skeleton-text" /> 175 <div 176 className="skeleton skeleton-text" 177 style={{ width: "60%" }} 178 /> 179 </div> 180 ))} 181 </div> 182 </div> 183 )} 184 185 {error && ( 186 <div className="empty-state"> 187 <div className="empty-state-icon"></div> 188 <h3 className="empty-state-title">Error</h3> 189 <p className="empty-state-text">{error}</p> 190 </div> 191 )} 192 193 {!loading && !error && totalItems === 0 && ( 194 <div className="empty-state"> 195 <div className="empty-state-icon"> 196 <SearchIcon size={32} /> 197 </div> 198 <h3 className="empty-state-title">No items found</h3> 199 <p className="empty-state-text"> 200 {displayName} hasn&apos;t annotated this page yet. 201 </p> 202 </div> 203 )} 204 205 {!loading && !error && totalItems > 0 && ( 206 <> 207 <div className="url-results-header"> 208 <h2 className="feed-title"> 209 {totalItems} item{totalItems !== 1 ? "s" : ""} 210 </h2> 211 <div className="feed-filters"> 212 <button 213 className={`filter-tab ${activeTab === "all" ? "active" : ""}`} 214 onClick={() => setActiveTab("all")} 215 > 216 All ({totalItems}) 217 </button> 218 <button 219 className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`} 220 onClick={() => setActiveTab("annotations")} 221 > 222 Annotations ({annotations.length}) 223 </button> 224 <button 225 className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`} 226 onClick={() => setActiveTab("highlights")} 227 > 228 Highlights ({highlights.length}) 229 </button> 230 </div> 231 </div> 232 <div className="feed-container"> 233 <div className="feed">{renderResults()}</div> 234 </div> 235 </> 236 )} 237 </div> 238 ); 239}