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