CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m8s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m29s
Operator live review of #253 on the wide window. The images landed well, but: - "the left rail feels like wasted space". The filters and check status move back into one row above the feed (PostsView). On a wide window the row lines up with the feed column, clear of the day gutter, and the container recentres without the 280px rail. The narrow layout is as before, with the status still first. This retires #407 option E; A and D stay. - The "you can't see" link landed on the Subscriptions reconcile card, which listed 9 sources as `former_patron` under "sources your roster doesn't account for". The operator doesn't want sources they no longer pay for listed there, and wants the card dismissable "unless something changes": - MembershipReconcileCard renders only "creators you subscribe to but don't follow here" and the stale-roster warning. - Stopping pulls on lapsed sources is the membership sweep's job instead (#3995, next). - A close button dismisses the card. The dismissal is keyed to a fingerprint of what the card says (offered membership ids plus stale-roster flags), so a new subscription or a roster going stale brings it back. It's stored per browser, which is enough for a single-operator instance. The backend still computes tracked_not_subscribed. #3995 acts on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
375 lines
13 KiB
Vue
375 lines
13 KiB
Vue
<template>
|
|
<!-- Width is set in CSS, not with `max-width` here: below 1600px it is
|
|
today's 900px column, and above it the feed widens and gains the day
|
|
gutter (milestone #407). -->
|
|
<v-container fluid class="pt-2 pb-6 fc-posts">
|
|
<!-- 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 -->
|
|
<div v-else class="fc-posts__main">
|
|
<!-- Filters and status sit in one row above the feed. #407 option E put
|
|
them in a sticky left rail; the operator found it wasted space
|
|
(2026-09-13), so on a wide window this row lines up with the feed
|
|
column instead, clear of the day gutter. -->
|
|
<div class="fc-posts__toolbar">
|
|
<FeedStatusRibbon v-if="statusRibbon" class="fc-posts__status" />
|
|
<PostsFilterBar
|
|
:artist-id="artistFilter"
|
|
:platform="platformFilter"
|
|
@update:filters="onFilters"
|
|
/>
|
|
</div>
|
|
|
|
<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>
|
|
<!-- Day groups (#407 D). The heading sits above its posts on a narrow
|
|
window and in a sticky left gutter on a wide one. -->
|
|
<section v-for="d in days" :key="d.key" class="fc-posts__day">
|
|
<header class="fc-posts__day-head">
|
|
<span class="fc-posts__day-label">{{ d.label }}</span>
|
|
<span class="fc-posts__day-count">
|
|
{{ d.posts.length }} post{{ d.posts.length === 1 ? '' : 's' }}
|
|
· {{ d.artistCount }} artist{{ d.artistCount === 1 ? '' : 's' }}
|
|
</span>
|
|
</header>
|
|
<div class="fc-posts__day-posts">
|
|
<PostCard v-for="p in d.posts" :key="p.id" :post="p" />
|
|
</div>
|
|
</section>
|
|
|
|
<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>
|
|
</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
|
|
)
|
|
|
|
// --- day groups (#407 D) ---
|
|
// CONSECUTIVE runs, not a bucket per date. The feed's sort key includes
|
|
// `resurfaced_at` (a Discord grouping that grew moves back to the top), which
|
|
// the payload does not carry, so a resurfaced post can sit above newer ones.
|
|
// Bucketing by date would pull it out of order; a run gives it its own heading
|
|
// where it actually appears. Counts cover what has LOADED, and grow as the
|
|
// infinite scroll fetches more of the same day.
|
|
function dayKey (iso) {
|
|
const d = new Date(iso)
|
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
|
}
|
|
function dayLabel (iso) {
|
|
const d = new Date(iso)
|
|
const today = new Date()
|
|
const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
|
|
const days = Math.round((startOf(today) - startOf(d)) / 86400000)
|
|
if (days === 0) return 'Today'
|
|
if (days === 1) return 'Yesterday'
|
|
if (days > 1 && days < 7) return d.toLocaleDateString(undefined, { weekday: 'long' })
|
|
const sameYear = d.getFullYear() === today.getFullYear()
|
|
return d.toLocaleDateString(undefined, {
|
|
month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }),
|
|
})
|
|
}
|
|
const days = computed(() => {
|
|
const groups = []
|
|
for (const p of store.items) {
|
|
const iso = p.post_date || p.downloaded_at
|
|
const key = dayKey(iso)
|
|
let g = groups[groups.length - 1]
|
|
if (!g || g.dayKey !== key) {
|
|
// Suffix with the run index so a day that appears twice (see above)
|
|
// still has a unique v-for key.
|
|
g = { key: `${key}#${groups.length}`, dayKey: key, label: dayLabel(iso), posts: [], artists: new Set() }
|
|
groups.push(g)
|
|
}
|
|
g.posts.push(p)
|
|
if (p.artist?.id != null) g.artists.add(p.artist.id)
|
|
}
|
|
return groups.map((g) => ({ ...g, artistCount: g.artists.size }))
|
|
})
|
|
|
|
// 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>
|
|
/* Below the breakpoint: today's layout exactly — a 900px column with the
|
|
filters and ribbon inline above the feed. */
|
|
.fc-posts { max-width: 900px; }
|
|
.fc-posts__day-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 10px;
|
|
padding: 4px 0 8px;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-posts__day-label {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-posts__day-count { font-size: 0.78rem; }
|
|
|
|
/* Wide window (#407 A + D). Each day's heading moves into a sticky gutter
|
|
beside its posts; the column widens and the cards switch to their filmstrip
|
|
layout on their own (PostCard measures itself). The toolbar lines up with the
|
|
feed column, clear of the gutter. */
|
|
@media (min-width: 1600px) {
|
|
.fc-posts { max-width: 2080px; }
|
|
.fc-posts__main { max-width: 2080px; margin: 0 auto; }
|
|
.fc-posts__toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 8px 24px;
|
|
margin-left: calc(150px + 24px);
|
|
margin-bottom: 8px;
|
|
}
|
|
/* Status reads after the filters on a wide row, but stays first when the
|
|
narrow layout stacks them (it is the front door's headline). */
|
|
.fc-posts__status { order: 2; padding: 0; }
|
|
.fc-posts__toolbar :deep(.fc-posts-filters) { padding-bottom: 0; }
|
|
.fc-posts__day {
|
|
display: grid;
|
|
grid-template-columns: 150px minmax(0, 1fr);
|
|
gap: 0 24px;
|
|
}
|
|
.fc-posts__day-head {
|
|
position: sticky;
|
|
top: calc(var(--fc-nav-h, 64px) + 16px);
|
|
align-self: start;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 2px;
|
|
padding-top: 12px;
|
|
text-align: right;
|
|
}
|
|
.fc-posts__day-label { font-size: 1.1rem; }
|
|
}
|
|
|
|
.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>
|