feat(posts): in-context anchored feed with bidirectional infinite scroll

Provenance "View post" deep-links to /posts?post_id=X, which now opens the
feed centered on that post with infinite load in BOTH directions.

Backend: PostFeedService.scroll gains a direction (older|newer); new
around(post_id) returns a window of newer + the post + older with a cursor
for each end. /api/posts accepts ?around= and ?direction=. + API tests.

Frontend: posts store gains loadAround/loadOlder/loadNewer (older appends,
newer prepends) with per-end cursors; PostsView's anchored mode scrolls to
the post, observes top + bottom sentinels, and preserves scroll position on
upward prepend so the page doesn't jump. Normal feed mode unchanged.

Closes the remaining half of the post-navigation work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 21:12:18 -04:00
parent 35fe420701
commit c95b760294
5 changed files with 382 additions and 54 deletions
+65
View File
@@ -12,6 +12,14 @@ export const usePostsStore = defineStore('posts', () => {
const done = ref(false)
const filters = ref({ artist_id: null, platform: null })
// In-context (anchored) view: bidirectional cursors so the feed can load
// newer posts on scroll-up and older posts on scroll-down around a post.
const cursorOlder = ref(null)
const cursorNewer = ref(null)
const doneOlder = ref(false)
const doneNewer = ref(false)
const anchorId = ref(null)
function _qs() {
const q = {}
if (cursor.value) q.cursor = cursor.value
@@ -51,8 +59,65 @@ export const usePostsStore = defineStore('posts', () => {
return await api.get(`/api/posts/${id}`)
}
// Load a window centered on `postId`: newer posts above, the post, older
// posts below. Sets both directional cursors for subsequent scrolling.
async function loadAround(postId) {
loading.value = true
error.value = null
anchorId.value = null
try {
const body = await api.get('/api/posts', { params: { around: postId } })
items.value = body.items
cursorOlder.value = body.cursor_older
cursorNewer.value = body.cursor_newer
doneOlder.value = body.cursor_older == null
doneNewer.value = body.cursor_newer == null
anchorId.value = body.anchor_id
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function loadOlder() {
if (loading.value || doneOlder.value || cursorOlder.value == null) return
loading.value = true
try {
const body = await api.get('/api/posts', {
params: { cursor: cursorOlder.value, direction: 'older' },
})
items.value.push(...body.items)
cursorOlder.value = body.next_cursor
if (body.next_cursor == null) doneOlder.value = true
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function loadNewer() {
if (loading.value || doneNewer.value || cursorNewer.value == null) return
loading.value = true
try {
const body = await api.get('/api/posts', {
params: { cursor: cursorNewer.value, direction: 'newer' },
})
items.value.unshift(...body.items)
cursorNewer.value = body.next_cursor
if (body.next_cursor == null) doneNewer.value = true
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return {
items, cursor, loading, done, error, filters,
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
loadInitial, loadMore, getPostFull,
loadAround, loadOlder, loadNewer,
}
})