Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 275 lines 9.1 kB view raw
1import React, { useState, useEffect, useCallback, useRef } from "react"; 2import { Loader2, ExternalLink, Compass, Tag } from "lucide-react"; 3import { useStore } from "@nanostores/react"; 4import { clsx } from "clsx"; 5import { getDocuments, getRecommendations } from "../../api/client"; 6import type { DocumentItem } from "../../api/client"; 7import { Tabs, EmptyState } from "../../components/ui"; 8import LayoutToggle from "../../components/ui/LayoutToggle"; 9import { $user } from "../../store/auth"; 10import { $feedLayout } from "../../store/feedLayout"; 11import { formatDistanceToNow } from "date-fns"; 12 13interface DiscoverProps { 14 initialDocuments?: DocumentItem[]; 15 initialHasMore?: boolean; 16} 17 18export default function Discover({ 19 initialDocuments, 20 initialHasMore, 21}: DiscoverProps) { 22 const user = useStore($user); 23 const layout = useStore($feedLayout); 24 const [activeTab, setActiveTab] = useState("new"); 25 const [items, setItems] = useState<DocumentItem[]>(initialDocuments || []); 26 const [loading, setLoading] = useState(!initialDocuments); 27 const [hasMore, setHasMore] = useState(initialHasMore ?? false); 28 const [offset, setOffset] = useState(initialDocuments?.length ?? 0); 29 const [recommendationsUnavailable, setRecommendationsUnavailable] = 30 useState(false); 31 const fetchIdRef = useRef(0); 32 const limit = 30; 33 34 const tabs = [ 35 { id: "new", label: "New" }, 36 { id: "popular", label: "Popular" }, 37 ...(user ? [{ id: "recommended", label: "For You" }] : []), 38 ]; 39 40 const fetchItems = useCallback( 41 async (tab: string, newOffset = 0, append = false) => { 42 const id = ++fetchIdRef.current; 43 setLoading(true); 44 45 let data: { items: DocumentItem[]; totalItems: number }; 46 if (tab === "recommended") { 47 const res = await getRecommendations(limit); 48 if ("unavailable" in res && res.unavailable) { 49 setRecommendationsUnavailable(true); 50 setLoading(false); 51 return; 52 } 53 setRecommendationsUnavailable(false); 54 data = res; 55 } else { 56 data = await getDocuments({ sort: tab, limit, offset: newOffset }); 57 } 58 59 if (id !== fetchIdRef.current) return; 60 61 setItems((prev) => (append ? [...prev, ...data.items] : data.items)); 62 setHasMore( 63 tab !== "recommended" && 64 newOffset + data.items.length < data.totalItems, 65 ); 66 setOffset(newOffset + data.items.length); 67 setLoading(false); 68 }, 69 [limit], 70 ); 71 72 const skipInitialFetch = useRef(!!initialDocuments); 73 useEffect(() => { 74 if (skipInitialFetch.current) { 75 skipInitialFetch.current = false; 76 return; 77 } 78 queueMicrotask(() => fetchItems(activeTab, 0)); 79 }, [activeTab, fetchItems]); 80 81 const handleTabChange = (id: string) => { 82 if (id === activeTab) return; 83 setActiveTab(id); 84 window.scrollTo({ top: 0, behavior: "smooth" }); 85 }; 86 87 const loadMore = () => { 88 fetchItems(activeTab, offset, true); 89 }; 90 91 return ( 92 <div className="mx-auto max-w-2xl xl:max-w-none"> 93 <div className="sticky top-0 z-10 bg-white/90 dark:bg-surface-800/90 backdrop-blur-md pb-3 mb-2 -mx-1 px-1 pt-2 space-y-2"> 94 <div className="flex items-center gap-2"> 95 <Tabs tabs={tabs} activeTab={activeTab} onChange={handleTabChange} /> 96 <LayoutToggle className="hidden sm:inline-flex ml-auto" /> 97 </div> 98 </div> 99 100 {loading && items.length === 0 ? ( 101 <div className="flex justify-center py-20"> 102 <Loader2 className="w-6 h-6 animate-spin text-surface-400" /> 103 </div> 104 ) : activeTab === "recommended" && recommendationsUnavailable ? ( 105 <EmptyState 106 icon={<Compass size={40} />} 107 title="Coming soon" 108 message="Personalized recommendations aren't available on this server yet." 109 /> 110 ) : items.length === 0 ? ( 111 <EmptyState 112 icon={<Compass size={40} />} 113 title="Nothing here yet" 114 message={ 115 activeTab === "recommended" 116 ? "Start annotating and highlighting to get personalized recommendations." 117 : "No documents have been discovered yet. Check back soon!" 118 } 119 /> 120 ) : ( 121 <div 122 className={clsx( 123 layout === "mosaic" 124 ? "columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4" 125 : "space-y-3", 126 "animate-fade-in", 127 )} 128 > 129 {items.map((doc) => ( 130 <div 131 key={doc.uri} 132 className={ 133 layout === "mosaic" ? "break-inside-avoid mb-4" : undefined 134 } 135 > 136 <DocumentCard doc={doc} /> 137 </div> 138 ))} 139 140 {loading && ( 141 <div className="flex justify-center py-6"> 142 <Loader2 className="w-5 h-5 animate-spin text-surface-400" /> 143 </div> 144 )} 145 146 {hasMore && !loading && ( 147 <button 148 onClick={loadMore} 149 className="w-full py-3 text-sm font-medium text-surface-500 hover:text-surface-700 dark:text-surface-400 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors" 150 > 151 Load more 152 </button> 153 )} 154 </div> 155 )} 156 </div> 157 ); 158} 159 160function DocumentCard({ doc }: { doc: DocumentItem }) { 161 const [ogData, setOgData] = useState<{ 162 title?: string; 163 description?: string; 164 image?: string; 165 icon?: string; 166 } | null>(() => { 167 try { 168 const cached = sessionStorage.getItem(`og:${doc.canonicalUrl}`); 169 return cached ? JSON.parse(cached) : null; 170 } catch { 171 return null; 172 } 173 }); 174 175 useEffect(() => { 176 if (!doc.canonicalUrl || ogData) return; 177 fetch(`/api/url-metadata?url=${encodeURIComponent(doc.canonicalUrl)}`) 178 .then((res) => (res.ok ? res.json() : null)) 179 .then((data) => { 180 if (data) { 181 setOgData(data); 182 try { 183 sessionStorage.setItem( 184 `og:${doc.canonicalUrl}`, 185 JSON.stringify(data), 186 ); 187 } catch { 188 /* quota exceeded */ 189 } 190 } 191 }) 192 .catch(() => {}); 193 }, [doc.canonicalUrl, ogData]); 194 195 const displayUrl = doc.canonicalUrl 196 .replace(/^https?:\/\//, "") 197 .replace(/\/$/, ""); 198 199 const hostname = (() => { 200 try { 201 return new URL(doc.canonicalUrl).hostname; 202 } catch { 203 return null; 204 } 205 })(); 206 207 return ( 208 <a 209 href={doc.canonicalUrl} 210 target="_blank" 211 rel="noopener noreferrer" 212 className="card block hover:ring-1 hover:ring-black/10 dark:hover:ring-white/10 transition-all group overflow-hidden" 213 > 214 {ogData?.image && ( 215 <div className="w-full h-40 bg-surface-100 dark:bg-surface-800 overflow-hidden"> 216 <img 217 src={ogData.image} 218 alt="" 219 className="w-full h-full object-cover" 220 onError={(e) => (e.currentTarget.style.display = "none")} 221 /> 222 </div> 223 )} 224 <div className="p-4"> 225 <div className="flex items-start justify-between gap-3"> 226 <div className="min-w-0 flex-1"> 227 <h3 className="font-display font-semibold text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors line-clamp-2"> 228 {doc.title || displayUrl} 229 </h3> 230 {doc.description && ( 231 <p className="mt-1 text-sm text-surface-500 dark:text-surface-400 line-clamp-2"> 232 {doc.description} 233 </p> 234 )} 235 <div className="mt-2 flex items-center gap-3 text-xs text-surface-400 dark:text-surface-500"> 236 <span className="flex items-center gap-1 truncate"> 237 {ogData?.icon ? ( 238 <img 239 src={ogData.icon} 240 alt="" 241 className="w-3 h-3 rounded-sm" 242 onError={(e) => (e.currentTarget.style.display = "none")} 243 /> 244 ) : ( 245 <ExternalLink size={12} /> 246 )} 247 {hostname || displayUrl} 248 </span> 249 {doc.publishedAt && ( 250 <span> 251 {formatDistanceToNow(new Date(doc.publishedAt), { 252 addSuffix: true, 253 })} 254 </span> 255 )} 256 </div> 257 {doc.tags && doc.tags.length > 0 && ( 258 <div className="mt-2 flex flex-wrap gap-1.5"> 259 {doc.tags.slice(0, 5).map((tag) => ( 260 <span 261 key={tag} 262 className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400" 263 > 264 <Tag size={10} /> 265 {tag} 266 </span> 267 ))} 268 </div> 269 )} 270 </div> 271 </div> 272 </div> 273 </a> 274 ); 275}