fc3e: PostsView + /posts route — IntersectionObserver infinite scroll, URL-driven filters

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:56:40 -04:00
parent b1a358c413
commit 6fb9e1ddf8
2 changed files with 108 additions and 0 deletions
+2
View File
@@ -9,6 +9,7 @@ import SeriesReaderView from './views/SeriesReaderView.vue'
import SubscriptionsView from './views/SubscriptionsView.vue'
import CredentialsView from './views/CredentialsView.vue'
import DownloadsView from './views/DownloadsView.vue'
import PostsView from './views/PostsView.vue'
// The application's front door. `/` redirects here. Changing the front door
// is a one-line edit (e.g. '/gallery' or '/tags').
@@ -31,6 +32,7 @@ const routes = [
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// FC-3: subscription backbone
{ path: '/posts', name: 'posts', component: PostsView, meta: { title: 'Posts' } },
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
{ path: '/credentials', name: 'credentials', component: CredentialsView, meta: { title: 'Credentials' } },
{ path: '/downloads', name: 'downloads', component: DownloadsView, meta: { title: 'Downloads' } }
+106
View File
@@ -0,0 +1,106 @@
<template>
<v-container class="py-6" max-width="900">
<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>
</v-container>
</template>
<script setup>
import { computed, 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 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 })
}
watch(() => [artistFilter.value, platformFilter.value], reload)
onMounted(async () => {
await reload()
observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
store.loadMore()
}
}, { rootMargin: '400px 0px' })
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => {
if (observer) observer.disconnect()
})
</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;
}
</style>