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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user