Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

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

+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 + }