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,
}
})
+150 -40
View File
@@ -1,39 +1,84 @@
<template>
<v-container class="pt-2 pb-6" max-width="900">
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<!-- In-context view: deep-linked to one post, with bidirectional infinite
scroll newer posts load above, older posts below. -->
<template v-if="postIdFilter != null">
<v-btn
variant="text" size="small" prepend-icon="mdi-arrow-left"
:to="{ name: 'posts' }" class="mb-2"
>All posts</v-btn>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<p>No posts yet. Subscribe to a source on the
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
</div>
<div v-else>
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
<div ref="sentinel" class="fc-posts__sentinel">
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
</div>
<div v-else>
<div ref="topSentinel" class="fc-posts__sentinel">
<v-progress-circular
v-if="store.loading && !store.doneNewer"
indeterminate color="accent" size="20"
/>
<span v-else-if="store.doneNewer" class="fc-posts__end">Top of feed</span>
</div>
<div
v-for="p in store.items" :key="p.id" :id="`fc-post-${p.id}`"
:class="['fc-posts__entry', { 'fc-posts__entry--anchor': p.id === store.anchorId }]"
>
<PostCard :post="p" />
</div>
<div ref="bottomSentinel" class="fc-posts__sentinel">
<v-progress-circular
v-if="store.loading && !store.doneOlder"
indeterminate color="accent" size="24"
/>
<span v-else-if="store.doneOlder" class="fc-posts__end">End of stream</span>
</div>
</div>
</template>
<!-- Normal feed -->
<template v-else>
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<p>No posts yet. Subscribe to a source on the
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
</div>
<div v-else>
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
<div ref="sentinel" class="fc-posts__sentinel">
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
</div>
</div>
</template>
</v-container>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePostsStore } from '../stores/posts.js'
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
@@ -48,7 +93,12 @@ const artistFilter = computed(() => {
return raw == null ? null : Number(raw)
})
const platformFilter = computed(() => route.query.platform || null)
const postIdFilter = computed(() => {
const raw = route.query.post_id
return raw == null ? null : Number(raw)
})
// --- normal feed (downward infinite scroll) ---
const sentinel = ref(null)
let observer = null
@@ -58,7 +108,6 @@ async function reload() {
platform: platformFilter.value,
})
}
function onFilters({ artist_id, platform }) {
const q = { ...route.query }
if (artist_id == null) delete q.artist_id
@@ -67,22 +116,79 @@ function onFilters({ artist_id, platform }) {
else q.platform = platform
router.replace({ query: q })
}
watch(() => [artistFilter.value, platformFilter.value], reload)
onMounted(async () => {
await reload()
function teardownFeed() {
if (observer) { observer.disconnect(); observer = null }
}
function setupFeedObserver() {
teardownFeed()
if (!sentinel.value) return
observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
store.loadMore()
}
if (entries.some((e) => e.isIntersecting)) store.loadMore()
}, { rootMargin: '400px 0px' })
if (sentinel.value) observer.observe(sentinel.value)
observer.observe(sentinel.value)
}
// --- in-context view (bidirectional, anchored on a post) ---
const topSentinel = ref(null)
const bottomSentinel = ref(null)
let topObs = null
let bottomObs = null
function teardownAround() {
if (topObs) { topObs.disconnect(); topObs = null }
if (bottomObs) { bottomObs.disconnect(); bottomObs = null }
}
function setupAroundObservers() {
teardownAround()
bottomObs = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) store.loadOlder()
}, { rootMargin: '500px 0px' })
if (bottomSentinel.value) bottomObs.observe(bottomSentinel.value)
topObs = new IntersectionObserver(async (entries) => {
if (!entries.some((e) => e.isIntersecting)) return
if (store.loading || store.doneNewer) return
// Preserve the viewport position across an upward prepend so the page
// doesn't jump when newer posts insert above the current view.
const beforeH = document.documentElement.scrollHeight
const beforeY = window.scrollY
await store.loadNewer()
await nextTick()
const afterH = document.documentElement.scrollHeight
window.scrollTo(0, beforeY + (afterH - beforeH))
}, { rootMargin: '200px 0px' })
if (topSentinel.value) topObs.observe(topSentinel.value)
}
async function loadAroundAndAnchor() {
teardownFeed()
await store.loadAround(postIdFilter.value)
await nextTick()
const el = document.getElementById(`fc-post-${store.anchorId}`)
if (el) el.scrollIntoView({ block: 'center' })
setupAroundObservers()
}
async function activate() {
if (postIdFilter.value != null) {
await loadAroundAndAnchor()
} else {
teardownAround()
await reload()
await nextTick()
setupFeedObserver()
}
}
watch(postIdFilter, activate)
watch(() => [artistFilter.value, platformFilter.value], async () => {
if (postIdFilter.value != null) return
await reload()
await nextTick()
setupFeedObserver()
})
onUnmounted(() => {
if (observer) observer.disconnect()
})
onMounted(activate)
onUnmounted(() => { teardownFeed(); teardownAround() })
</script>
<style scoped>
@@ -103,4 +209,8 @@ onUnmounted(() => {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem;
}
.fc-posts__entry--anchor :deep(.fc-post-card) {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 3px;
}
</style>