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