Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 415 lines 13 kB view raw
1import { useState, useEffect, useMemo, useCallback } from "react"; 2import { useSearchParams } from "react-router-dom"; 3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4import BookmarkCard from "../components/BookmarkCard"; 5import CollectionItemCard from "../components/CollectionItemCard"; 6import AnnotationSkeleton from "../components/AnnotationSkeleton"; 7import IOSInstallBanner from "../components/IOSInstallBanner"; 8import { getAnnotationFeed, deleteHighlight } from "../api/client"; 9import { AlertIcon, InboxIcon } from "../components/Icons"; 10import { useAuth } from "../context/AuthContext"; 11import { X, ArrowUp } from "lucide-react"; 12 13import AddToCollectionModal from "../components/AddToCollectionModal"; 14 15export default function Feed() { 16 const [searchParams, setSearchParams] = useSearchParams(); 17 const tagFilter = searchParams.get("tag"); 18 19 const [filter, setFilter] = useState(() => { 20 return localStorage.getItem("feedFilter") || "all"; 21 }); 22 23 const [feedType, setFeedType] = useState(() => { 24 return localStorage.getItem("feedType") || "all"; 25 }); 26 27 const [annotations, setAnnotations] = useState([]); 28 const [loading, setLoading] = useState(true); 29 const [error, setError] = useState(null); 30 const [hasMore, setHasMore] = useState(true); 31 const [loadingMore, setLoadingMore] = useState(false); 32 33 useEffect(() => { 34 localStorage.setItem("feedFilter", filter); 35 }, [filter]); 36 37 useEffect(() => { 38 localStorage.setItem("feedType", feedType); 39 }, [feedType]); 40 41 const [collectionModalState, setCollectionModalState] = useState({ 42 isOpen: false, 43 uri: null, 44 }); 45 46 const { user } = useAuth(); 47 48 const fetchFeed = useCallback( 49 async (isLoadMore = false) => { 50 try { 51 if (isLoadMore) { 52 setLoadingMore(true); 53 } else { 54 setLoading(true); 55 } 56 57 let creatorDid = ""; 58 59 if (feedType === "my-feed") { 60 if (user?.did) { 61 creatorDid = user.did; 62 } else { 63 setAnnotations([]); 64 setLoading(false); 65 setLoadingMore(false); 66 return; 67 } 68 } 69 70 const motivationMap = { 71 commenting: "commenting", 72 highlighting: "highlighting", 73 bookmarking: "bookmarking", 74 }; 75 const motivation = motivationMap[filter] || ""; 76 const limit = 50; 77 const offset = isLoadMore ? annotations.length : 0; 78 79 const data = await getAnnotationFeed( 80 limit, 81 offset, 82 tagFilter || "", 83 creatorDid, 84 feedType, 85 motivation, 86 ); 87 88 const newItems = data.items || []; 89 if (newItems.length < limit) { 90 setHasMore(false); 91 } else { 92 setHasMore(true); 93 } 94 95 if (isLoadMore) { 96 setAnnotations((prev) => [...prev, ...newItems]); 97 } else { 98 setAnnotations(newItems); 99 } 100 } catch (err) { 101 setError(err.message); 102 } finally { 103 setLoading(false); 104 setLoadingMore(false); 105 } 106 }, 107 [tagFilter, feedType, filter, user, annotations.length], 108 ); 109 110 useEffect(() => { 111 fetchFeed(false); 112 }, [fetchFeed]); 113 114 const deduplicatedAnnotations = useMemo(() => { 115 const inCollectionUris = new Set(); 116 for (const item of annotations) { 117 if (item.type === "CollectionItem") { 118 const inner = item.annotation || item.highlight || item.bookmark; 119 if (inner) { 120 if (inner.uri) inCollectionUris.add(inner.uri.trim()); 121 if (inner.id) inCollectionUris.add(inner.id.trim()); 122 } 123 } 124 } 125 126 const result = []; 127 128 for (const item of annotations) { 129 if (item.type !== "CollectionItem") { 130 const itemUri = (item.uri || "").trim(); 131 const itemId = (item.id || "").trim(); 132 if ( 133 (itemUri && inCollectionUris.has(itemUri)) || 134 (itemId && inCollectionUris.has(itemId)) 135 ) { 136 continue; 137 } 138 } 139 140 result.push(item); 141 } 142 143 return result; 144 }, [annotations]); 145 146 const filteredAnnotations = 147 feedType === "all" || 148 feedType === "popular" || 149 feedType === "semble" || 150 feedType === "margin" || 151 feedType === "my-feed" 152 ? filter === "all" 153 ? deduplicatedAnnotations 154 : deduplicatedAnnotations.filter((a) => { 155 if (a.type === "CollectionItem") { 156 if (filter === "commenting") return !!a.annotation; 157 if (filter === "highlighting") return !!a.highlight; 158 if (filter === "bookmarking") return !!a.bookmark; 159 } 160 if (filter === "commenting") 161 return a.motivation === "commenting" || a.type === "Annotation"; 162 if (filter === "highlighting") 163 return a.motivation === "highlighting" || a.type === "Highlight"; 164 if (filter === "bookmarking") 165 return a.motivation === "bookmarking" || a.type === "Bookmark"; 166 return a.motivation === filter; 167 }) 168 : deduplicatedAnnotations; 169 170 return ( 171 <div className="feed-page"> 172 <div className="page-header"> 173 <h1 className="page-title">Feed</h1> 174 <p className="page-description"> 175 See what people are annotating and bookmarking 176 </p> 177 </div> 178 179 {tagFilter && ( 180 <div className="active-filter-banner"> 181 <span> 182 Filtering by <strong>#{tagFilter}</strong> 183 </span> 184 <button 185 onClick={() => 186 setSearchParams((prev) => { 187 const next = new URLSearchParams(prev); 188 next.delete("tag"); 189 return next; 190 }) 191 } 192 className="active-filter-clear" 193 aria-label="Clear filter" 194 > 195 <X size={14} /> 196 </button> 197 </div> 198 )} 199 200 <div className="feed-controls"> 201 <div className="feed-filters"> 202 {[ 203 { key: "all", label: "All" }, 204 { key: "popular", label: "Popular" }, 205 { key: "margin", label: "Margin" }, 206 { key: "semble", label: "Semble" }, 207 ...(user ? [{ key: "my-feed", label: "Mine" }] : []), 208 ].map(({ key, label }) => ( 209 <button 210 key={key} 211 className={`filter-tab ${feedType === key ? "active" : ""}`} 212 onClick={() => setFeedType(key)} 213 > 214 {label} 215 </button> 216 ))} 217 </div> 218 219 <div className="feed-filters"> 220 {[ 221 { key: "all", label: "All" }, 222 { key: "commenting", label: "Notes" }, 223 { key: "highlighting", label: "Highlights" }, 224 { key: "bookmarking", label: "Bookmarks" }, 225 ].map(({ key, label }) => ( 226 <button 227 key={key} 228 className={`filter-pill ${filter === key ? "active" : ""}`} 229 onClick={() => setFilter(key)} 230 > 231 {label} 232 </button> 233 ))} 234 </div> 235 </div> 236 237 <IOSInstallBanner /> 238 239 {loading ? ( 240 <div className="feed-container"> 241 <div className="feed"> 242 {[1, 2, 3, 4, 5].map((i) => ( 243 <AnnotationSkeleton key={i} /> 244 ))} 245 </div> 246 </div> 247 ) : ( 248 <> 249 {error && ( 250 <div className="empty-state"> 251 <div className="empty-state-icon"> 252 <AlertIcon size={24} /> 253 </div> 254 <h3 className="empty-state-title">Something went wrong</h3> 255 <p className="empty-state-text">{error}</p> 256 </div> 257 )} 258 259 {!error && filteredAnnotations.length === 0 && ( 260 <div className="empty-state"> 261 <div className="empty-state-icon"> 262 <InboxIcon size={24} /> 263 </div> 264 <h3 className="empty-state-title">No items yet</h3> 265 <p className="empty-state-text"> 266 {filter === "all" 267 ? "Be the first to annotate something!" 268 : `No ${filter} items found.`} 269 </p> 270 </div> 271 )} 272 273 {!error && filteredAnnotations.length > 0 && ( 274 <div className="feed-container"> 275 <div className="feed"> 276 {filteredAnnotations.map((item) => { 277 if (item.type === "CollectionItem") { 278 return ( 279 <CollectionItemCard 280 key={item.id} 281 item={item} 282 onAddToCollection={(uri) => 283 setCollectionModalState({ 284 isOpen: true, 285 uri: uri, 286 }) 287 } 288 /> 289 ); 290 } 291 if ( 292 item.type === "Highlight" || 293 item.motivation === "highlighting" 294 ) { 295 return ( 296 <HighlightCard 297 key={item.id} 298 highlight={item} 299 onDelete={async (uri) => { 300 const rkey = uri.split("/").pop(); 301 await deleteHighlight(rkey); 302 setAnnotations((prev) => 303 prev.filter((a) => a.id !== item.id), 304 ); 305 }} 306 onAddToCollection={() => 307 setCollectionModalState({ 308 isOpen: true, 309 uri: item.uri || item.id, 310 }) 311 } 312 /> 313 ); 314 } 315 if ( 316 item.type === "Bookmark" || 317 item.motivation === "bookmarking" 318 ) { 319 return ( 320 <BookmarkCard 321 key={item.id} 322 bookmark={item} 323 onAddToCollection={() => 324 setCollectionModalState({ 325 isOpen: true, 326 uri: item.uri || item.id, 327 }) 328 } 329 /> 330 ); 331 } 332 return ( 333 <AnnotationCard 334 key={item.id} 335 annotation={item} 336 onAddToCollection={() => 337 setCollectionModalState({ 338 isOpen: true, 339 uri: item.uri || item.id, 340 }) 341 } 342 /> 343 ); 344 })} 345 </div> 346 347 {hasMore && ( 348 <div 349 style={{ 350 display: "flex", 351 justifyContent: "center", 352 marginTop: "12px", 353 paddingBottom: "24px", 354 }} 355 > 356 <button 357 onClick={() => fetchFeed(true)} 358 disabled={loadingMore} 359 className="feed-load-more" 360 > 361 {loadingMore ? "Loading..." : "View More"} 362 </button> 363 </div> 364 )} 365 </div> 366 )} 367 </> 368 )} 369 370 {collectionModalState.isOpen && ( 371 <AddToCollectionModal 372 isOpen={collectionModalState.isOpen} 373 onClose={() => setCollectionModalState({ isOpen: false, uri: null })} 374 annotationUri={collectionModalState.uri} 375 /> 376 )} 377 378 <BackToTopButton /> 379 </div> 380 ); 381} 382 383function BackToTopButton() { 384 const [isVisible, setIsVisible] = useState(false); 385 386 useEffect(() => { 387 const toggleVisibility = () => { 388 if (window.scrollY > 300) { 389 setIsVisible(true); 390 } else { 391 setIsVisible(false); 392 } 393 }; 394 395 window.addEventListener("scroll", toggleVisibility); 396 return () => window.removeEventListener("scroll", toggleVisibility); 397 }, []); 398 399 const scrollToTop = () => { 400 window.scrollTo({ 401 top: 0, 402 behavior: "smooth", 403 }); 404 }; 405 406 return ( 407 <button 408 className={`back-to-top-btn ${isVisible ? "visible" : ""}`} 409 onClick={scrollToTop} 410 aria-label="Back to top" 411 > 412 <ArrowUp size={20} /> 413 </button> 414 ); 415}