Files
FabledCurator/frontend/src/views/PostsView.vue
T
bvandeusenandClaude Opus 5 6b19012bb6
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 6s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 2m3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
feat: the empty front door is the install's first screen (milestone 387 step B4)
The front door is now a feed, and a blank feed implies things should be
here in a way a blank masonry does not. On a fresh install this is the
first screen anyone sees — including someone who is not the operator,
which is what milestone 328 is making possible.

Tells the two empties apart, which is the point. "No sources yet" gets
the on-ramp; "sources configured, nothing landed yet" gets told that
the first check takes a while and pointed at Downloads. Telling someone
to add a source when they already have three and are mid-backfill reads
as the app not knowing its own state.

Needs total_sources on schedule-status to distinguish them —
deliberately not auto_sources, which counts only what is on a schedule,
so a source with auto_check off would have read as "nothing
configured". Both exact-shape assertions updated in THIS change rather
than after CI caught them, which is the lesson from B3's red push.

An absent status falls back to the on-ramp on purpose: it is merely
redundant to an established operator, whereas "see what's running"
shown to someone with nothing configured is a dead end.

A filtered miss is deliberately NOT the onboarding case — the operator
has posts, they just narrowed past them. Showing a fresh-install
on-ramp there would tell someone with a full library to go set it up.

This is where logo.svg lands, as the operator asked. It earns its place
on a first-run screen and not on a populated feed, and gives the
on-ramp something to compose around instead of prose plus two buttons.
Large: the mark stops reading below ~48px, which is why the 22px nav
slot has a different one. Pinned by test so a later tidy-up cannot
quietly shrink it to a glyph.

Also extracts mountWithStore into the shared test support module.
Writing the second spec created exactly the copy-paste that open issue
3109 tracks for the backend row factories, so it is consolidated now
rather than at copy three, and recorded as snippet 3829 with the two
traps it does NOT solve — named slots rendering nothing, and
components that fetch on mount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
2026-09-09 23:10:06 -04:00

257 lines
8.7 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">
<!-- Returns to whichever surface you deep-linked FROM. This used to be a
hard `{ name: 'posts' }`, which redirects into Browse — fine while
this view only ever rendered inside Browse's tab, but it now also
serves the front door (#387 B1), where it would have yanked the
operator sideways into a different view. -->
<v-btn
variant="text" size="small" prepend-icon="mdi-arrow-left"
:to="allPostsTarget" 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>
<FeedStatusRibbon v-if="statusRibbon" />
<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">
<!-- A filtered miss is NOT the onboarding case: the operator has posts,
they just narrowed past them. Showing a fresh-install on-ramp here
would be telling someone with a full library to go and set it up. -->
<p v-if="hasActiveFilter">No posts match your search or filters.</p>
<FeedEmptyState v-else />
</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'
import FeedStatusRibbon from '../components/posts/FeedStatusRibbon.vue'
import FeedEmptyState from '../components/posts/FeedEmptyState.vue'
// The ingestion-status ribbon is a FRONT-DOOR concern, not a feed concern —
// inside Browse's Posts tab you are looking FOR something, and the Subscriptions
// hub is a click away. Passed as a route prop rather than sniffed from
// route.name so the view does not have to know what it is mounted as, and so
// the router file states the intent in one place.
defineProps({
statusRibbon: { type: Boolean, default: false },
})
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 searchFilter = computed(() => route.query.q || null)
const postIdFilter = computed(() => {
const raw = route.query.post_id
return raw == null ? null : Number(raw)
})
const hasActiveFilter = computed(() =>
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
)
// Drop only `post_id` and stay where we are — keeps Browse's `tab=posts` (and
// any active artist/platform scope) intact instead of resetting the surface.
const allPostsTarget = computed(() => {
const { post_id: _drop, ...rest } = route.query
return { name: route.name, query: rest }
})
// --- 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,
q: searchFilter.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,
q: searchFilter.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, searchFilter.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>