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