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:
@@ -18,6 +18,8 @@ async def list_posts():
|
|||||||
artist_id_raw = args.get("artist_id")
|
artist_id_raw = args.get("artist_id")
|
||||||
platform = args.get("platform") or None
|
platform = args.get("platform") or None
|
||||||
limit_raw = args.get("limit", "24")
|
limit_raw = args.get("limit", "24")
|
||||||
|
direction = args.get("direction", "older")
|
||||||
|
around_raw = args.get("around")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
limit = int(limit_raw)
|
limit = int(limit_raw)
|
||||||
@@ -26,6 +28,16 @@ async def list_posts():
|
|||||||
if limit < 1 or limit > 100:
|
if limit < 1 or limit > 100:
|
||||||
return _bad("invalid_limit", detail="limit must be between 1 and 100")
|
return _bad("invalid_limit", detail="limit must be between 1 and 100")
|
||||||
|
|
||||||
|
if direction not in ("older", "newer"):
|
||||||
|
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
|
||||||
|
|
||||||
|
around_id = None
|
||||||
|
if around_raw is not None:
|
||||||
|
try:
|
||||||
|
around_id = int(around_raw)
|
||||||
|
except ValueError:
|
||||||
|
return _bad("invalid_around", detail="around must be an integer post id")
|
||||||
|
|
||||||
artist_id = None
|
artist_id = None
|
||||||
if artist_id_raw is not None:
|
if artist_id_raw is not None:
|
||||||
try:
|
try:
|
||||||
@@ -40,11 +52,20 @@ async def list_posts():
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
try:
|
svc = PostFeedService(session)
|
||||||
page = await PostFeedService(session).scroll(
|
if around_id is not None:
|
||||||
cursor=cursor, artist_id=artist_id,
|
result = await svc.around(
|
||||||
|
post_id=around_id, artist_id=artist_id,
|
||||||
platform=platform, limit=limit,
|
platform=platform, limit=limit,
|
||||||
)
|
)
|
||||||
|
if result is None:
|
||||||
|
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
||||||
|
return jsonify(result)
|
||||||
|
try:
|
||||||
|
page = await svc.scroll(
|
||||||
|
cursor=cursor, artist_id=artist_id,
|
||||||
|
platform=platform, limit=limit, direction=direction,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# Service raises ValueError for malformed cursors only;
|
# Service raises ValueError for malformed cursors only;
|
||||||
# limit bounds are validated above.
|
# limit bounds are validated above.
|
||||||
|
|||||||
@@ -56,9 +56,17 @@ class PostFeedService:
|
|||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
limit: int = 24,
|
limit: int = 24,
|
||||||
|
direction: str = "older",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Paginate the feed from `cursor`. direction='older' walks back in
|
||||||
|
time (default, infinite-scroll down); direction='newer' walks forward
|
||||||
|
(scroll up in an anchored view). Items are always returned in feed
|
||||||
|
(descending) order; `next_cursor` points to the far edge in the
|
||||||
|
requested direction (null when exhausted)."""
|
||||||
if limit < 1 or limit > 100:
|
if limit < 1 or limit > 100:
|
||||||
raise ValueError("limit must be between 1 and 100")
|
raise ValueError("limit must be between 1 and 100")
|
||||||
|
if direction not in ("older", "newer"):
|
||||||
|
raise ValueError("direction must be 'older' or 'newer'")
|
||||||
|
|
||||||
sort_key = _sort_key()
|
sort_key = _sort_key()
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -72,22 +80,37 @@ class PostFeedService:
|
|||||||
stmt = stmt.where(Source.platform == platform)
|
stmt = stmt.where(Source.platform == platform)
|
||||||
if cursor:
|
if cursor:
|
||||||
cur_ts, cur_id = decode_cursor(cursor)
|
cur_ts, cur_id = decode_cursor(cursor)
|
||||||
stmt = stmt.where(
|
if direction == "older":
|
||||||
or_(
|
stmt = stmt.where(or_(
|
||||||
sort_key < cur_ts,
|
sort_key < cur_ts,
|
||||||
and_(sort_key == cur_ts, Post.id < cur_id),
|
and_(sort_key == cur_ts, Post.id < cur_id),
|
||||||
)
|
))
|
||||||
)
|
else:
|
||||||
|
stmt = stmt.where(or_(
|
||||||
|
sort_key > cur_ts,
|
||||||
|
and_(sort_key == cur_ts, Post.id > cur_id),
|
||||||
|
))
|
||||||
|
|
||||||
stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1)
|
if direction == "older":
|
||||||
|
stmt = stmt.order_by(sort_key.desc(), Post.id.desc())
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(sort_key.asc(), Post.id.asc())
|
||||||
|
stmt = stmt.limit(limit + 1)
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
if direction == "newer":
|
||||||
|
# Fetched ascending (closest-newer first); flip to feed order.
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
|
||||||
next_cursor: str | None = None
|
next_cursor: str | None = None
|
||||||
if len(rows) > limit:
|
if has_more and rows:
|
||||||
last_post, _, _ = rows[limit - 1]
|
# Far edge in the travel direction: oldest row going older,
|
||||||
last_key = last_post.post_date or last_post.downloaded_at
|
# newest row going newer (rows is descending for display).
|
||||||
next_cursor = encode_cursor(last_key, last_post.id)
|
edge_post = rows[-1][0] if direction == "older" else rows[0][0]
|
||||||
rows = rows[:limit]
|
edge_key = edge_post.post_date or edge_post.downloaded_at
|
||||||
|
next_cursor = encode_cursor(edge_key, edge_post.id)
|
||||||
|
|
||||||
post_ids = [p.id for p, _, _ in rows]
|
post_ids = [p.id for p, _, _ in rows]
|
||||||
thumbs_map = await self._thumbnails_for(post_ids)
|
thumbs_map = await self._thumbnails_for(post_ids)
|
||||||
@@ -99,6 +122,49 @@ class PostFeedService:
|
|||||||
]
|
]
|
||||||
return {"items": items, "next_cursor": next_cursor}
|
return {"items": items, "next_cursor": next_cursor}
|
||||||
|
|
||||||
|
async def around(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
post_id: int,
|
||||||
|
artist_id: int | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
limit: int = 12,
|
||||||
|
) -> dict | None:
|
||||||
|
"""A window centered on `post_id`: up to `limit` newer posts + the
|
||||||
|
post + up to `limit` older posts, in feed (descending) order, with a
|
||||||
|
cursor for each end. Returns None if the post doesn't exist."""
|
||||||
|
anchor = (await self.session.execute(
|
||||||
|
select(Post, Artist, Source)
|
||||||
|
.join(Source, Post.source_id == Source.id)
|
||||||
|
.join(Artist, Source.artist_id == Artist.id)
|
||||||
|
.where(Post.id == post_id)
|
||||||
|
)).one_or_none()
|
||||||
|
if anchor is None:
|
||||||
|
return None
|
||||||
|
anchor_post, anchor_artist, anchor_source = anchor
|
||||||
|
anchor_key = anchor_post.post_date or anchor_post.downloaded_at
|
||||||
|
anchor_cursor = encode_cursor(anchor_key, anchor_post.id)
|
||||||
|
|
||||||
|
older = await self.scroll(
|
||||||
|
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
||||||
|
limit=limit, direction="older",
|
||||||
|
)
|
||||||
|
newer = await self.scroll(
|
||||||
|
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
||||||
|
limit=limit, direction="newer",
|
||||||
|
)
|
||||||
|
thumbs_map = await self._thumbnails_for([anchor_post.id])
|
||||||
|
atts_map = await self._attachments_for([anchor_post.id])
|
||||||
|
anchor_item = self._to_dict(
|
||||||
|
anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"items": newer["items"] + [anchor_item] + older["items"],
|
||||||
|
"cursor_older": older["next_cursor"],
|
||||||
|
"cursor_newer": newer["next_cursor"],
|
||||||
|
"anchor_id": anchor_post.id,
|
||||||
|
}
|
||||||
|
|
||||||
async def get_post(self, post_id: int) -> dict | None:
|
async def get_post(self, post_id: int) -> dict | None:
|
||||||
row = (await self.session.execute(
|
row = (await self.session.execute(
|
||||||
select(Post, Artist, Source)
|
select(Post, Artist, Source)
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
const done = ref(false)
|
const done = ref(false)
|
||||||
const filters = ref({ artist_id: null, platform: null })
|
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() {
|
function _qs() {
|
||||||
const q = {}
|
const q = {}
|
||||||
if (cursor.value) q.cursor = cursor.value
|
if (cursor.value) q.cursor = cursor.value
|
||||||
@@ -51,8 +59,65 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
return await api.get(`/api/posts/${id}`)
|
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 {
|
return {
|
||||||
items, cursor, loading, done, error, filters,
|
items, cursor, loading, done, error, filters,
|
||||||
|
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
|
||||||
loadInitial, loadMore, getPostFull,
|
loadInitial, loadMore, getPostFull,
|
||||||
|
loadAround, loadOlder, loadNewer,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,39 +1,84 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="pt-2 pb-6" max-width="900">
|
<v-container class="pt-2 pb-6" max-width="900">
|
||||||
<PostsFilterBar
|
<!-- In-context view: deep-linked to one post, with bidirectional infinite
|
||||||
:artist-id="artistFilter"
|
scroll — newer posts load above, older posts below. -->
|
||||||
:platform="platformFilter"
|
<template v-if="postIdFilter != null">
|
||||||
@update:filters="onFilters"
|
<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">
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||||
{{ String(store.error) }}
|
{{ String(store.error) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
|
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
|
||||||
<v-progress-circular indeterminate color="accent" size="36" />
|
<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>
|
||||||
</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>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { usePostsStore } from '../stores/posts.js'
|
import { usePostsStore } from '../stores/posts.js'
|
||||||
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
|
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
|
||||||
@@ -48,7 +93,12 @@ const artistFilter = computed(() => {
|
|||||||
return raw == null ? null : Number(raw)
|
return raw == null ? null : Number(raw)
|
||||||
})
|
})
|
||||||
const platformFilter = computed(() => route.query.platform || null)
|
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)
|
const sentinel = ref(null)
|
||||||
let observer = null
|
let observer = null
|
||||||
|
|
||||||
@@ -58,7 +108,6 @@ async function reload() {
|
|||||||
platform: platformFilter.value,
|
platform: platformFilter.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFilters({ artist_id, platform }) {
|
function onFilters({ artist_id, platform }) {
|
||||||
const q = { ...route.query }
|
const q = { ...route.query }
|
||||||
if (artist_id == null) delete q.artist_id
|
if (artist_id == null) delete q.artist_id
|
||||||
@@ -67,22 +116,79 @@ function onFilters({ artist_id, platform }) {
|
|||||||
else q.platform = platform
|
else q.platform = platform
|
||||||
router.replace({ query: q })
|
router.replace({ query: q })
|
||||||
}
|
}
|
||||||
|
function teardownFeed() {
|
||||||
watch(() => [artistFilter.value, platformFilter.value], reload)
|
if (observer) { observer.disconnect(); observer = null }
|
||||||
|
}
|
||||||
onMounted(async () => {
|
function setupFeedObserver() {
|
||||||
await reload()
|
teardownFeed()
|
||||||
|
if (!sentinel.value) return
|
||||||
observer = new IntersectionObserver((entries) => {
|
observer = new IntersectionObserver((entries) => {
|
||||||
if (entries.some(e => e.isIntersecting)) {
|
if (entries.some((e) => e.isIntersecting)) store.loadMore()
|
||||||
store.loadMore()
|
|
||||||
}
|
|
||||||
}, { rootMargin: '400px 0px' })
|
}, { 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(() => {
|
onMounted(activate)
|
||||||
if (observer) observer.disconnect()
|
onUnmounted(() => { teardownFeed(); teardownAround() })
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -103,4 +209,8 @@ onUnmounted(() => {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.fc-posts__entry--anchor :deep(.fc-post-card) {
|
||||||
|
outline: 2px solid rgb(var(--v-theme-accent));
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+67
-1
@@ -4,7 +4,7 @@ Validates list shape, cursor handling, filter validation, and detail
|
|||||||
endpoint. Service-level fixtures are exercised by test_post_feed_service
|
endpoint. Service-level fixtures are exercised by test_post_feed_service
|
||||||
— here we focus on the HTTP surface (validation, status codes, dict shape).
|
— here we focus on the HTTP surface (validation, status codes, dict shape).
|
||||||
"""
|
"""
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -107,6 +107,72 @@ async def test_detail_404_for_unknown(client):
|
|||||||
assert body["error"] == "not_found"
|
assert body["error"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def post_timeline(db):
|
||||||
|
"""Five posts on distinct dates: posts[0] oldest … posts[4] newest."""
|
||||||
|
artist = Artist(name="tl-api", slug="tl-api")
|
||||||
|
db.add(artist)
|
||||||
|
await db.flush()
|
||||||
|
source = Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://p/tl-api", enabled=True,
|
||||||
|
)
|
||||||
|
db.add(source)
|
||||||
|
await db.flush()
|
||||||
|
base = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
posts = []
|
||||||
|
for i in range(5):
|
||||||
|
p = Post(
|
||||||
|
source_id=source.id, external_post_id=f"TL{i}",
|
||||||
|
post_title=f"post {i}", post_date=base + timedelta(days=i),
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
posts.append(p)
|
||||||
|
await db.commit()
|
||||||
|
return artist, source, posts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_around_returns_window_with_anchor(client, post_timeline):
|
||||||
|
_, _, posts = post_timeline
|
||||||
|
anchor = posts[2]
|
||||||
|
resp = await client.get(f"/api/posts?around={anchor.id}&limit=1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert set(body.keys()) == {"items", "cursor_older", "cursor_newer", "anchor_id"}
|
||||||
|
assert body["anchor_id"] == anchor.id
|
||||||
|
# limit=1: one newer + anchor + one older, in feed (desc) order.
|
||||||
|
assert [it["id"] for it in body["items"]] == [posts[3].id, posts[2].id, posts[1].id]
|
||||||
|
assert body["cursor_older"] is not None # posts[0] still older
|
||||||
|
assert body["cursor_newer"] is not None # posts[4] still newer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_around_404_for_unknown(client):
|
||||||
|
resp = await client.get("/api/posts?around=999999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert (await resp.get_json())["error"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direction_newer_walks_forward(client, post_timeline):
|
||||||
|
_, _, posts = post_timeline
|
||||||
|
around = await client.get(f"/api/posts?around={posts[1].id}&limit=1")
|
||||||
|
cursor_newer = (await around.get_json())["cursor_newer"]
|
||||||
|
assert cursor_newer is not None
|
||||||
|
resp = await client.get(f"/api/posts?cursor={cursor_newer}&direction=newer&limit=5")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Newer than the window's newest (posts[2]) → posts[3], posts[4] in desc order.
|
||||||
|
assert [it["id"] for it in (await resp.get_json())["items"]] == [posts[4].id, posts[3].id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_bad_direction(client):
|
||||||
|
resp = await client.get("/api/posts?direction=sideways")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert (await resp.get_json())["error"] == "invalid_direction"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_detail_returns_uncapped_thumbnails(client, db):
|
async def test_detail_returns_uncapped_thumbnails(client, db):
|
||||||
"""Feed query caps thumbnails at 6 for previews; detail endpoint
|
"""Feed query caps thumbnails at 6 for previews; detail endpoint
|
||||||
|
|||||||
Reference in New Issue
Block a user