(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}