(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.

Back to top and view more buttons in feed and refined My-Feed pagination logic

scanash00 8f210ad0 65d8338a

+204 -49
+31 -16
backend/internal/api/handler.go
··· 140 140 141 141 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 142 142 limit := parseIntParam(r, "limit", 50) 143 + offset := parseIntParam(r, "offset", 0) 143 144 tag := r.URL.Query().Get("tag") 144 145 creator := r.URL.Query().Get("creator") 145 146 feedType := r.URL.Query().Get("type") ··· 148 149 149 150 if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { 150 151 if creator == viewerDID { 151 - h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) 152 + h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit, offset) 152 153 return 153 154 } 154 155 } ··· 161 162 162 163 motivation := r.URL.Query().Get("motivation") 163 164 165 + fetchLimit := limit + offset 166 + 164 167 if tag != "" { 165 168 if creator != "" { 166 169 if motivation == "" || motivation == "commenting" { 167 - annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 170 + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) 168 171 } 169 172 if motivation == "" || motivation == "highlighting" { 170 - highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 173 + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) 171 174 } 172 175 if motivation == "" || motivation == "bookmarking" { 173 - bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 176 + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) 174 177 } 175 178 collectionItems = []db.CollectionItem{} 176 179 } else { 177 180 if motivation == "" || motivation == "commenting" { 178 - annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 181 + annotations, _ = h.db.GetAnnotationsByTag(tag, fetchLimit, 0) 179 182 } 180 183 if motivation == "" || motivation == "highlighting" { 181 - highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 184 + highlights, _ = h.db.GetHighlightsByTag(tag, fetchLimit, 0) 182 185 } 183 186 if motivation == "" || motivation == "bookmarking" { 184 - bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 187 + bookmarks, _ = h.db.GetBookmarksByTag(tag, fetchLimit, 0) 185 188 } 186 189 collectionItems = []db.CollectionItem{} 187 190 } 188 191 } else if creator != "" { 189 192 if motivation == "" || motivation == "commenting" { 190 - annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 193 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, fetchLimit, 0) 191 194 } 192 195 if motivation == "" || motivation == "highlighting" { 193 - highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 196 + highlights, _ = h.db.GetHighlightsByAuthor(creator, fetchLimit, 0) 194 197 } 195 198 if motivation == "" || motivation == "bookmarking" { 196 - bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 199 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, fetchLimit, 0) 197 200 } 198 201 collectionItems = []db.CollectionItem{} 199 202 } else { 200 203 if motivation == "" || motivation == "commenting" { 201 - annotations, _ = h.db.GetRecentAnnotations(limit, 0) 204 + annotations, _ = h.db.GetRecentAnnotations(fetchLimit, 0) 202 205 } 203 206 if motivation == "" || motivation == "highlighting" { 204 - highlights, _ = h.db.GetRecentHighlights(limit, 0) 207 + highlights, _ = h.db.GetRecentHighlights(fetchLimit, 0) 205 208 } 206 209 if motivation == "" || motivation == "bookmarking" { 207 - bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 210 + bookmarks, _ = h.db.GetRecentBookmarks(fetchLimit, 0) 208 211 } 209 212 if motivation == "" { 210 - collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 213 + collectionItems, err = h.db.GetRecentCollectionItems(fetchLimit, 0) 211 214 if err != nil { 212 215 log.Printf("Error fetching collection items: %v\n", err) 213 216 } ··· 284 287 sortFeed(feed) 285 288 } 286 289 290 + if offset < len(feed) { 291 + feed = feed[offset:] 292 + } else { 293 + feed = []interface{}{} 294 + } 295 + 287 296 if len(feed) > limit { 288 297 feed = feed[:limit] 289 298 } ··· 297 306 }) 298 307 } 299 308 300 - func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit int) { 309 + func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit, offset int) { 301 310 var wg sync.WaitGroup 302 311 var rawAnnos, rawHighs, rawBooks []interface{} 303 312 var errAnnos, errHighs, errBooks error 304 313 305 - fetchLimit := limit * 2 314 + fetchLimit := limit + offset 306 315 if fetchLimit < 50 { 307 316 fetchLimit = 50 308 317 } ··· 414 423 } 415 424 416 425 sortFeed(feed) 426 + 427 + if offset < len(feed) { 428 + feed = feed[offset:] 429 + } else { 430 + feed = []interface{}{} 431 + } 417 432 418 433 if len(feed) > limit { 419 434 feed = feed[:limit]
+61
web/src/css/feed.css
··· 17 17 position: relative; 18 18 } 19 19 20 + .feed-load-more { 21 + display: inline-flex; 22 + align-items: center; 23 + justify-content: center; 24 + padding: 10px 24px; 25 + background: var(--bg-tertiary); 26 + border: none; 27 + border-radius: var(--radius-md); 28 + color: var(--text-secondary); 29 + font-weight: 500; 30 + font-size: 0.9rem; 31 + cursor: pointer; 32 + transition: all 0.15s ease; 33 + } 34 + 35 + .feed-load-more:hover { 36 + background: var(--bg-hover); 37 + color: var(--text-primary); 38 + } 39 + 40 + .feed-load-more:disabled { 41 + opacity: 0.6; 42 + cursor: not-allowed; 43 + } 44 + 20 45 .feed > * { 21 46 background: var(--bg-card); 22 47 border: 1px solid var(--border); ··· 370 395 font-family: var(--font-mono); 371 396 color: var(--text-secondary); 372 397 } 398 + 399 + .back-to-top-btn { 400 + position: fixed; 401 + bottom: 24px; 402 + right: 24px; 403 + width: 44px; 404 + height: 44px; 405 + border-radius: var(--radius-full); 406 + background: var(--bg-tertiary); 407 + border: 1px solid var(--border); 408 + color: var(--text-secondary); 409 + display: flex; 410 + align-items: center; 411 + justify-content: center; 412 + cursor: pointer; 413 + box-shadow: var(--shadow-md); 414 + transition: all 0.2s ease; 415 + z-index: 100; 416 + opacity: 0; 417 + visibility: hidden; 418 + transform: translateY(10px); 419 + } 420 + 421 + .back-to-top-btn.visible { 422 + opacity: 1; 423 + visibility: visible; 424 + transform: translateY(0); 425 + } 426 + 427 + .back-to-top-btn:hover { 428 + background: var(--bg-hover); 429 + color: var(--text-primary); 430 + border-color: var(--accent); 431 + transform: translateY(-2px); 432 + box-shadow: var(--shadow-lg); 433 + }
+112 -33
web/src/pages/Feed.jsx
··· 8 8 import { getAnnotationFeed, deleteHighlight } from "../api/client"; 9 9 import { AlertIcon, InboxIcon } from "../components/Icons"; 10 10 import { useAuth } from "../context/AuthContext"; 11 - import { X } from "lucide-react"; 11 + import { X, ArrowUp } from "lucide-react"; 12 12 13 13 import AddToCollectionModal from "../components/AddToCollectionModal"; 14 14 ··· 27 27 const [annotations, setAnnotations] = useState([]); 28 28 const [loading, setLoading] = useState(true); 29 29 const [error, setError] = useState(null); 30 + const [hasMore, setHasMore] = useState(true); 31 + const [loadingMore, setLoadingMore] = useState(false); 30 32 31 33 useEffect(() => { 32 34 localStorage.setItem("feedFilter", filter); ··· 43 45 44 46 const { user } = useAuth(); 45 47 46 - useEffect(() => { 47 - async function fetchFeed() { 48 - try { 48 + const fetchFeed = async (isLoadMore = false) => { 49 + try { 50 + if (isLoadMore) { 51 + setLoadingMore(true); 52 + } else { 49 53 setLoading(true); 50 - let creatorDid = ""; 54 + } 51 55 52 - if (feedType === "my-feed") { 53 - if (user?.did) { 54 - creatorDid = user.did; 55 - } else { 56 - setAnnotations([]); 57 - setLoading(false); 58 - return; 59 - } 56 + let creatorDid = ""; 57 + 58 + if (feedType === "my-feed") { 59 + if (user?.did) { 60 + creatorDid = user.did; 61 + } else { 62 + setAnnotations([]); 63 + setLoading(false); 64 + setLoadingMore(false); 65 + return; 60 66 } 67 + } 61 68 62 - const motivationMap = { 63 - commenting: "commenting", 64 - highlighting: "highlighting", 65 - bookmarking: "bookmarking", 66 - }; 67 - const motivation = motivationMap[filter] || ""; 69 + const motivationMap = { 70 + commenting: "commenting", 71 + highlighting: "highlighting", 72 + bookmarking: "bookmarking", 73 + }; 74 + const motivation = motivationMap[filter] || ""; 75 + const limit = 50; 76 + const offset = isLoadMore ? annotations.length : 0; 68 77 69 - const data = await getAnnotationFeed( 70 - 50, 71 - 0, 72 - tagFilter || "", 73 - creatorDid, 74 - feedType, 75 - motivation, 76 - ); 77 - setAnnotations(data.items || []); 78 - } catch (err) { 79 - setError(err.message); 80 - } finally { 81 - setLoading(false); 78 + const data = await getAnnotationFeed( 79 + limit, 80 + offset, 81 + tagFilter || "", 82 + creatorDid, 83 + feedType, 84 + motivation, 85 + ); 86 + 87 + const newItems = data.items || []; 88 + if (newItems.length < limit) { 89 + setHasMore(false); 90 + } else { 91 + setHasMore(true); 92 + } 93 + 94 + if (isLoadMore) { 95 + setAnnotations((prev) => [...prev, ...newItems]); 96 + } else { 97 + setAnnotations(newItems); 82 98 } 99 + } catch (err) { 100 + setError(err.message); 101 + } finally { 102 + setLoading(false); 103 + setLoadingMore(false); 83 104 } 84 - fetchFeed(); 105 + }; 106 + 107 + useEffect(() => { 108 + fetchFeed(false); 85 109 }, [tagFilter, feedType, filter, user]); 86 110 87 111 const deduplicatedAnnotations = useMemo(() => { ··· 316 340 ); 317 341 })} 318 342 </div> 343 + 344 + {hasMore && ( 345 + <div 346 + style={{ 347 + display: "flex", 348 + justifyContent: "center", 349 + marginTop: "12px", 350 + paddingBottom: "24px", 351 + }} 352 + > 353 + <button 354 + onClick={() => fetchFeed(true)} 355 + disabled={loadingMore} 356 + className="feed-load-more" 357 + > 358 + {loadingMore ? "Loading..." : "View More"} 359 + </button> 360 + </div> 361 + )} 319 362 </div> 320 363 )} 321 364 </> ··· 328 371 annotationUri={collectionModalState.uri} 329 372 /> 330 373 )} 374 + 375 + <BackToTopButton /> 331 376 </div> 332 377 ); 333 378 } 379 + 380 + function BackToTopButton() { 381 + const [isVisible, setIsVisible] = useState(false); 382 + 383 + useEffect(() => { 384 + const toggleVisibility = () => { 385 + if (window.scrollY > 300) { 386 + setIsVisible(true); 387 + } else { 388 + setIsVisible(false); 389 + } 390 + }; 391 + 392 + window.addEventListener("scroll", toggleVisibility); 393 + return () => window.removeEventListener("scroll", toggleVisibility); 394 + }, []); 395 + 396 + const scrollToTop = () => { 397 + window.scrollTo({ 398 + top: 0, 399 + behavior: "smooth", 400 + }); 401 + }; 402 + 403 + return ( 404 + <button 405 + className={`back-to-top-btn ${isVisible ? "visible" : ""}`} 406 + onClick={scrollToTop} 407 + aria-label="Back to top" 408 + > 409 + <ArrowUp size={20} /> 410 + </button> 411 + ); 412 + }