Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 353 lines 12 kB view raw
1import { useStore } from "@nanostores/react"; 2import { 3 AlertTriangle, 4 Check, 5 Copy, 6 ExternalLink, 7 Globe, 8 Highlighter, 9 Loader2, 10 PenTool, 11 Search, 12 User, 13 Users, 14} from "lucide-react"; 15import React, { useCallback, useEffect, useRef, useState } from "react"; 16import { useNavigate, useParams } from "react-router-dom"; 17import { getByTarget } from "../../api/client"; 18import Card from "../../components/common/Card"; 19import { Button, EmptyState, Input, Tabs } from "../../components/ui"; 20import { $user } from "../../store/auth"; 21import type { AnnotationItem } from "../../types"; 22 23export default function UrlPage() { 24 const params = useParams(); 25 const navigate = useNavigate(); 26 const urlPath = params["*"]; 27 const targetUrl = urlPath ? decodeURIComponent(urlPath) : ""; 28 29 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 30 const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 31 const [loading, setLoading] = useState(true); 32 const [loadingMore, setLoadingMore] = useState(false); 33 const [loadMoreError, setLoadMoreError] = useState<string | null>(null); 34 const [hasMore, setHasMore] = useState(false); 35 const [offset, setOffset] = useState(0); 36 const [error, setError] = useState<string | null>(null); 37 const [activeTab, setActiveTab] = useState< 38 "all" | "annotations" | "highlights" 39 >("all"); 40 const [copied, setCopied] = useState(false); 41 const user = useStore($user); 42 43 const LIMIT = 50; 44 const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 45 46 useEffect(() => { 47 return () => { 48 if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); 49 }; 50 }, []); 51 52 useEffect(() => { 53 async function fetchData() { 54 if (!targetUrl) { 55 setLoading(false); 56 return; 57 } 58 59 try { 60 setLoading(true); 61 setError(null); 62 63 const data = await getByTarget(targetUrl, LIMIT, 0); 64 const fetchedAnnotations = data.annotations || []; 65 const fetchedHighlights = data.highlights || []; 66 setAnnotations(fetchedAnnotations); 67 setHighlights(fetchedHighlights); 68 const totalFetched = 69 fetchedAnnotations.length + fetchedHighlights.length; 70 setHasMore(totalFetched >= LIMIT); 71 setOffset(totalFetched); 72 } catch (err) { 73 setError(err instanceof Error ? err.message : "Failed to load data"); 74 } finally { 75 setLoading(false); 76 } 77 } 78 fetchData(); 79 }, [targetUrl]); 80 81 const loadMore = useCallback(async () => { 82 setLoadingMore(true); 83 setLoadMoreError(null); 84 try { 85 const data = await getByTarget(targetUrl, LIMIT, offset); 86 const fetchedAnnotations = data.annotations || []; 87 const fetchedHighlights = data.highlights || []; 88 setAnnotations((prev) => [...prev, ...fetchedAnnotations]); 89 setHighlights((prev) => [...prev, ...fetchedHighlights]); 90 const totalFetched = fetchedAnnotations.length + fetchedHighlights.length; 91 setHasMore(totalFetched >= LIMIT); 92 setOffset((prev) => prev + totalFetched); 93 } catch (err) { 94 console.error("Failed to load more:", err); 95 const msg = err instanceof Error ? err.message : "Something went wrong"; 96 setLoadMoreError(msg); 97 if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); 98 loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000); 99 } finally { 100 setLoadingMore(false); 101 } 102 }, [targetUrl, offset]); 103 104 const handleCopyLink = useCallback(async () => { 105 try { 106 await navigator.clipboard.writeText(window.location.href); 107 setCopied(true); 108 setTimeout(() => setCopied(false), 2000); 109 } catch (err) { 110 console.error("Failed to copy link:", err); 111 } 112 }, []); 113 114 const handleNavigateMyAnnotations = useCallback(async () => { 115 if (!user?.handle || !targetUrl) return; 116 navigate(`/${user.handle}/url/${encodeURIComponent(targetUrl)}`); 117 }, [user?.handle, targetUrl, navigate]); 118 119 const totalItems = annotations.length + highlights.length; 120 121 const uniqueAuthors = new Map< 122 string, 123 { did: string; handle?: string; displayName?: string; avatar?: string } 124 >(); 125 [...annotations, ...highlights].forEach((item) => { 126 const author = item.author || item.creator; 127 if (author?.did && !uniqueAuthors.has(author.did)) { 128 uniqueAuthors.set(author.did, author); 129 } 130 }); 131 const authorCount = uniqueAuthors.size; 132 133 const hostname = (() => { 134 try { 135 return new URL(targetUrl).hostname; 136 } catch { 137 return targetUrl; 138 } 139 })(); 140 141 const favicon = targetUrl 142 ? `https://www.google.com/s2/favicons?domain=${hostname}&sz=32` 143 : null; 144 145 if (!targetUrl) { 146 return ( 147 <div className="max-w-2xl mx-auto pb-20 animate-fade-in"> 148 <div className="text-center py-10"> 149 <div className="w-16 h-16 bg-primary-50 dark:bg-primary-900/20 rounded-2xl flex items-center justify-center mx-auto mb-6 rotate-3"> 150 <Globe 151 size={32} 152 className="text-primary-600 dark:text-primary-400" 153 /> 154 </div> 155 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-3"> 156 URL Annotations 157 </h1> 158 <p className="text-surface-500 dark:text-surface-400 max-w-md mx-auto mb-8"> 159 Enter a URL to see all public annotations and highlights from the 160 Margin community. 161 </p> 162 163 <form 164 onSubmit={(e) => { 165 e.preventDefault(); 166 const formData = new FormData(e.currentTarget); 167 const q = (formData.get("q") as string)?.trim(); 168 if (q) { 169 const encoded = encodeURIComponent(q); 170 navigate(`/url/${encoded}`); 171 } 172 }} 173 className="max-w-md mx-auto flex gap-2" 174 > 175 <div className="flex-1"> 176 <Input 177 name="q" 178 placeholder="https://example.com/article" 179 className="w-full bg-surface-50 dark:bg-surface-800" 180 autoFocus 181 /> 182 </div> 183 <Button type="submit">View</Button> 184 </form> 185 </div> 186 </div> 187 ); 188 } 189 190 const items = [ 191 ...(activeTab === "all" || activeTab === "annotations" ? annotations : []), 192 ...(activeTab === "all" || activeTab === "highlights" ? highlights : []), 193 ]; 194 195 if (activeTab === "all") { 196 items.sort((a, b) => { 197 const dateA = new Date(a.createdAt).getTime(); 198 const dateB = new Date(b.createdAt).getTime(); 199 return dateB - dateA; 200 }); 201 } 202 203 return ( 204 <div className="max-w-3xl mx-auto pb-20 animate-fade-in"> 205 <header className="mb-8 p-6 bg-white dark:bg-surface-800 rounded-2xl border border-surface-200 dark:border-surface-700 shadow-sm"> 206 <div className="flex items-start gap-4"> 207 {favicon && ( 208 <img 209 src={favicon} 210 alt="" 211 className="w-8 h-8 rounded-lg mt-1 shrink-0" 212 onError={(e) => { 213 (e.target as HTMLImageElement).style.display = "none"; 214 }} 215 /> 216 )} 217 <div className="flex-1 min-w-0"> 218 <h1 className="text-xl font-bold text-surface-900 dark:text-white mb-1 break-all"> 219 {hostname} 220 </h1> 221 <a 222 href={targetUrl} 223 target="_blank" 224 rel="noopener noreferrer" 225 className="text-sm text-primary-600 dark:text-primary-400 hover:underline break-all flex items-center gap-1 leading-relaxed" 226 > 227 <span className="truncate">{targetUrl}</span> 228 <ExternalLink size={12} className="shrink-0" /> 229 </a> 230 </div> 231 <div className="flex items-center gap-2 shrink-0"> 232 {user && ( 233 <button 234 onClick={handleNavigateMyAnnotations} 235 className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-200 text-sm font-medium rounded-lg transition-colors" 236 title="See your annotations for this page" 237 > 238 <User size={14} /> My Annotations 239 </button> 240 )} 241 <button 242 onClick={handleCopyLink} 243 className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-200 text-sm font-medium rounded-lg transition-colors" 244 title="Copy shareable link" 245 > 246 {copied ? <Check size={14} /> : <Copy size={14} />} 247 {copied ? "Copied!" : "Share"} 248 </button> 249 </div> 250 </div> 251 252 {!loading && totalItems > 0 && ( 253 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700 flex items-center gap-4 text-sm text-surface-500 dark:text-surface-400"> 254 <span className="flex items-center gap-1.5"> 255 <Users size={14} /> 256 {authorCount} contributor{authorCount !== 1 ? "s" : ""} 257 </span> 258 </div> 259 )} 260 </header> 261 262 {loading && ( 263 <div className="flex flex-col items-center justify-center py-20"> 264 <Loader2 265 className="animate-spin text-primary-600 dark:text-primary-400 mb-4" 266 size={32} 267 /> 268 <p className="text-surface-500 dark:text-surface-400"> 269 Loading annotations... 270 </p> 271 </div> 272 )} 273 274 {error && ( 275 <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"> 276 <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 277 <p>{error}</p> 278 </div> 279 )} 280 281 {!loading && !error && totalItems === 0 && ( 282 <EmptyState 283 icon={<Search size={48} />} 284 title="No annotations yet" 285 message="Nobody has annotated this page yet. Be the first — install the Margin extension and start annotating!" 286 /> 287 )} 288 289 {!loading && !error && totalItems > 0 && ( 290 <div> 291 <div className="mb-6"> 292 <Tabs 293 tabs={[ 294 { id: "all", label: "All" }, 295 { id: "annotations", label: "Annotations" }, 296 { id: "highlights", label: "Highlights" }, 297 ]} 298 activeTab={activeTab} 299 onChange={(id: string) => 300 setActiveTab(id as "all" | "annotations" | "highlights") 301 } 302 /> 303 </div> 304 305 <div className="space-y-4"> 306 {activeTab === "annotations" && annotations.length === 0 && ( 307 <EmptyState 308 icon={<PenTool size={32} />} 309 title="No annotations" 310 message="There are no annotations for this page yet." 311 /> 312 )} 313 {activeTab === "highlights" && highlights.length === 0 && ( 314 <EmptyState 315 icon={<Highlighter size={32} />} 316 title="No highlights" 317 message="There are no highlights for this page yet." 318 /> 319 )} 320 321 {items.map((item) => ( 322 <Card key={item.uri} item={item} /> 323 ))} 324 </div> 325 326 {hasMore && ( 327 <div className="flex flex-col items-center gap-2 py-6"> 328 {loadMoreError && ( 329 <p className="text-sm text-red-500 dark:text-red-400"> 330 Failed to load more: {loadMoreError} 331 </p> 332 )} 333 <button 334 onClick={loadMore} 335 disabled={loadingMore} 336 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" 337 > 338 {loadingMore ? ( 339 <> 340 <Loader2 size={16} className="animate-spin" /> 341 Loading... 342 </> 343 ) : ( 344 "Load more" 345 )} 346 </button> 347 </div> 348 )} 349 </div> 350 )} 351 </div> 352 ); 353}