fb605af959
Operator-flagged 2026-06-01: clicking a Provenance post title from the
view modal opens the post in the posts feed scoped to that artist
(`/posts?post_id=N&artist_id=A`), but the older/newer infinite-scroll
loaded UNFILTERED global posts instead of staying in the artist's
stream. Root cause: the posts store's loadAround/loadOlder/loadNewer
sent only `{around, cursor, direction}` — never the `artist_id` /
`platform` filters. Backend `/api/posts` accepts them on every path
(post_feed_service.scroll filters via `Source.artist_id`); the
frontend just wasn't passing them.
Fix:
- `posts.js` `loadAround(postId, filters)` now takes the filter
snapshot and stores it. `_aroundParams(extra)` mixes the active
filters into around/cursor calls.
- `PostsView.vue` `loadAroundAndAnchor` passes `artist_id` +
`platform` from the route query.
225 lines
7.0 KiB
Vue
225 lines
7.0 KiB
Vue
<template>
|
|
<v-container class="pt-2 pb-6" max-width="900">
|
|
<!-- 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>
|
|
|
|
<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>
|
|
<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, 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'
|
|
import PostCard from '../components/posts/PostCard.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const store = usePostsStore()
|
|
|
|
const artistFilter = computed(() => {
|
|
const raw = route.query.artist_id
|
|
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
|
|
|
|
async function reload() {
|
|
await store.loadInitial({
|
|
artist_id: artistFilter.value,
|
|
platform: platformFilter.value,
|
|
})
|
|
}
|
|
function onFilters({ artist_id, platform }) {
|
|
const q = { ...route.query }
|
|
if (artist_id == null) delete q.artist_id
|
|
else q.artist_id = String(artist_id)
|
|
if (!platform) delete q.platform
|
|
else q.platform = platform
|
|
router.replace({ query: q })
|
|
}
|
|
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()
|
|
}, { rootMargin: '400px 0px' })
|
|
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()
|
|
// Pass artist_id + platform through so the anchored view stays
|
|
// scoped — the older/newer infinite scrolls then read these filters
|
|
// back via the store's _aroundParams (operator-flagged 2026-06-01:
|
|
// post-title click from the modal landed scoped but the scroll then
|
|
// pulled unfiltered global posts).
|
|
await store.loadAround(postIdFilter.value, {
|
|
artist_id: artistFilter.value,
|
|
platform: platformFilter.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()
|
|
})
|
|
|
|
onMounted(activate)
|
|
onUnmounted(() => { teardownFeed(); teardownAround() })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-posts__loading,
|
|
.fc-posts__empty {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 2rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-posts__sentinel {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 1.5rem 0;
|
|
min-height: 2rem;
|
|
}
|
|
.fc-posts__end {
|
|
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>
|