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 141 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 142 limit := parseIntParam(r, "limit", 50) 143 tag := r.URL.Query().Get("tag") 144 creator := r.URL.Query().Get("creator") 145 feedType := r.URL.Query().Get("type") ··· 148 149 if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { 150 if creator == viewerDID { 151 - h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) 152 return 153 } 154 } ··· 161 162 motivation := r.URL.Query().Get("motivation") 163 164 if tag != "" { 165 if creator != "" { 166 if motivation == "" || motivation == "commenting" { 167 - annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 168 } 169 if motivation == "" || motivation == "highlighting" { 170 - highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 171 } 172 if motivation == "" || motivation == "bookmarking" { 173 - bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 174 } 175 collectionItems = []db.CollectionItem{} 176 } else { 177 if motivation == "" || motivation == "commenting" { 178 - annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 179 } 180 if motivation == "" || motivation == "highlighting" { 181 - highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 182 } 183 if motivation == "" || motivation == "bookmarking" { 184 - bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 185 } 186 collectionItems = []db.CollectionItem{} 187 } 188 } else if creator != "" { 189 if motivation == "" || motivation == "commenting" { 190 - annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 191 } 192 if motivation == "" || motivation == "highlighting" { 193 - highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 194 } 195 if motivation == "" || motivation == "bookmarking" { 196 - bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 197 } 198 collectionItems = []db.CollectionItem{} 199 } else { 200 if motivation == "" || motivation == "commenting" { 201 - annotations, _ = h.db.GetRecentAnnotations(limit, 0) 202 } 203 if motivation == "" || motivation == "highlighting" { 204 - highlights, _ = h.db.GetRecentHighlights(limit, 0) 205 } 206 if motivation == "" || motivation == "bookmarking" { 207 - bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 208 } 209 if motivation == "" { 210 - collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 211 if err != nil { 212 log.Printf("Error fetching collection items: %v\n", err) 213 } ··· 284 sortFeed(feed) 285 } 286 287 if len(feed) > limit { 288 feed = feed[:limit] 289 } ··· 297 }) 298 } 299 300 - func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit int) { 301 var wg sync.WaitGroup 302 var rawAnnos, rawHighs, rawBooks []interface{} 303 var errAnnos, errHighs, errBooks error 304 305 - fetchLimit := limit * 2 306 if fetchLimit < 50 { 307 fetchLimit = 50 308 } ··· 414 } 415 416 sortFeed(feed) 417 418 if len(feed) > limit { 419 feed = feed[:limit]
··· 140 141 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 142 limit := parseIntParam(r, "limit", 50) 143 + offset := parseIntParam(r, "offset", 0) 144 tag := r.URL.Query().Get("tag") 145 creator := r.URL.Query().Get("creator") 146 feedType := r.URL.Query().Get("type") ··· 149 150 if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { 151 if creator == viewerDID { 152 + h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit, offset) 153 return 154 } 155 } ··· 162 163 motivation := r.URL.Query().Get("motivation") 164 165 + fetchLimit := limit + offset 166 + 167 if tag != "" { 168 if creator != "" { 169 if motivation == "" || motivation == "commenting" { 170 + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) 171 } 172 if motivation == "" || motivation == "highlighting" { 173 + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) 174 } 175 if motivation == "" || motivation == "bookmarking" { 176 + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) 177 } 178 collectionItems = []db.CollectionItem{} 179 } else { 180 if motivation == "" || motivation == "commenting" { 181 + annotations, _ = h.db.GetAnnotationsByTag(tag, fetchLimit, 0) 182 } 183 if motivation == "" || motivation == "highlighting" { 184 + highlights, _ = h.db.GetHighlightsByTag(tag, fetchLimit, 0) 185 } 186 if motivation == "" || motivation == "bookmarking" { 187 + bookmarks, _ = h.db.GetBookmarksByTag(tag, fetchLimit, 0) 188 } 189 collectionItems = []db.CollectionItem{} 190 } 191 } else if creator != "" { 192 if motivation == "" || motivation == "commenting" { 193 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, fetchLimit, 0) 194 } 195 if motivation == "" || motivation == "highlighting" { 196 + highlights, _ = h.db.GetHighlightsByAuthor(creator, fetchLimit, 0) 197 } 198 if motivation == "" || motivation == "bookmarking" { 199 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, fetchLimit, 0) 200 } 201 collectionItems = []db.CollectionItem{} 202 } else { 203 if motivation == "" || motivation == "commenting" { 204 + annotations, _ = h.db.GetRecentAnnotations(fetchLimit, 0) 205 } 206 if motivation == "" || motivation == "highlighting" { 207 + highlights, _ = h.db.GetRecentHighlights(fetchLimit, 0) 208 } 209 if motivation == "" || motivation == "bookmarking" { 210 + bookmarks, _ = h.db.GetRecentBookmarks(fetchLimit, 0) 211 } 212 if motivation == "" { 213 + collectionItems, err = h.db.GetRecentCollectionItems(fetchLimit, 0) 214 if err != nil { 215 log.Printf("Error fetching collection items: %v\n", err) 216 } ··· 287 sortFeed(feed) 288 } 289 290 + if offset < len(feed) { 291 + feed = feed[offset:] 292 + } else { 293 + feed = []interface{}{} 294 + } 295 + 296 if len(feed) > limit { 297 feed = feed[:limit] 298 } ··· 306 }) 307 } 308 309 + func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit, offset int) { 310 var wg sync.WaitGroup 311 var rawAnnos, rawHighs, rawBooks []interface{} 312 var errAnnos, errHighs, errBooks error 313 314 + fetchLimit := limit + offset 315 if fetchLimit < 50 { 316 fetchLimit = 50 317 } ··· 423 } 424 425 sortFeed(feed) 426 + 427 + if offset < len(feed) { 428 + feed = feed[offset:] 429 + } else { 430 + feed = []interface{}{} 431 + } 432 433 if len(feed) > limit { 434 feed = feed[:limit]
+61
web/src/css/feed.css
··· 17 position: relative; 18 } 19 20 .feed > * { 21 background: var(--bg-card); 22 border: 1px solid var(--border); ··· 370 font-family: var(--font-mono); 371 color: var(--text-secondary); 372 }
··· 17 position: relative; 18 } 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 + 45 .feed > * { 46 background: var(--bg-card); 47 border: 1px solid var(--border); ··· 395 font-family: var(--font-mono); 396 color: var(--text-secondary); 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 import { getAnnotationFeed, deleteHighlight } from "../api/client"; 9 import { AlertIcon, InboxIcon } from "../components/Icons"; 10 import { useAuth } from "../context/AuthContext"; 11 - import { X } from "lucide-react"; 12 13 import AddToCollectionModal from "../components/AddToCollectionModal"; 14 ··· 27 const [annotations, setAnnotations] = useState([]); 28 const [loading, setLoading] = useState(true); 29 const [error, setError] = useState(null); 30 31 useEffect(() => { 32 localStorage.setItem("feedFilter", filter); ··· 43 44 const { user } = useAuth(); 45 46 - useEffect(() => { 47 - async function fetchFeed() { 48 - try { 49 setLoading(true); 50 - let creatorDid = ""; 51 52 - if (feedType === "my-feed") { 53 - if (user?.did) { 54 - creatorDid = user.did; 55 - } else { 56 - setAnnotations([]); 57 - setLoading(false); 58 - return; 59 - } 60 } 61 62 - const motivationMap = { 63 - commenting: "commenting", 64 - highlighting: "highlighting", 65 - bookmarking: "bookmarking", 66 - }; 67 - const motivation = motivationMap[filter] || ""; 68 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); 82 } 83 } 84 - fetchFeed(); 85 }, [tagFilter, feedType, filter, user]); 86 87 const deduplicatedAnnotations = useMemo(() => { ··· 316 ); 317 })} 318 </div> 319 </div> 320 )} 321 </> ··· 328 annotationUri={collectionModalState.uri} 329 /> 330 )} 331 </div> 332 ); 333 }
··· 8 import { getAnnotationFeed, deleteHighlight } from "../api/client"; 9 import { AlertIcon, InboxIcon } from "../components/Icons"; 10 import { useAuth } from "../context/AuthContext"; 11 + import { X, ArrowUp } from "lucide-react"; 12 13 import AddToCollectionModal from "../components/AddToCollectionModal"; 14 ··· 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); ··· 45 46 const { user } = useAuth(); 47 48 + const fetchFeed = async (isLoadMore = false) => { 49 + try { 50 + if (isLoadMore) { 51 + setLoadingMore(true); 52 + } else { 53 setLoading(true); 54 + } 55 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; 66 } 67 + } 68 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; 77 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); 98 } 99 + } catch (err) { 100 + setError(err.message); 101 + } finally { 102 + setLoading(false); 103 + setLoadingMore(false); 104 } 105 + }; 106 + 107 + useEffect(() => { 108 + fetchFeed(false); 109 }, [tagFilter, feedType, filter, user]); 110 111 const deduplicatedAnnotations = useMemo(() => { ··· 340 ); 341 })} 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 + )} 362 </div> 363 )} 364 </> ··· 371 annotationUri={collectionModalState.uri} 372 /> 373 )} 374 + 375 + <BackToTopButton /> 376 </div> 377 ); 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 + }