fix: the filter rail goes, and the subscriptions card stops listing what you don't pay for — and can be dismissed
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
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
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
<template>
|
||||
<!-- Renders nothing when there is nothing to say — same posture as
|
||||
NeedsAttentionCard. A reconciliation card that always shows would train
|
||||
the operator to scroll past it. -->
|
||||
<v-card v-if="anythingToSay" variant="tonal" class="mb-4 fc-recon">
|
||||
the operator to scroll past it. It can also be DISMISSED, and stays
|
||||
dismissed until what it would say changes. -->
|
||||
<v-card v-if="visible" variant="tonal" class="mb-4 fc-recon">
|
||||
<v-card-text>
|
||||
<v-btn
|
||||
icon="mdi-close" size="small" variant="text" class="fc-recon__dismiss"
|
||||
title="Hide until something changes" aria-label="Hide until something changes"
|
||||
@click="dismiss"
|
||||
/>
|
||||
<div v-for="p in interesting" :key="p.platform" class="fc-recon__platform">
|
||||
<!-- Bucket 1: the adoption win. The only direction with an action,
|
||||
because adding a source is the reversible half. -->
|
||||
<!-- The adoption win: subscriptions FC doesn't follow yet. The only
|
||||
bucket shown. Sources you follow but no longer pay for are not
|
||||
listed here (operator, 2026-09-13) — the membership sweep stops
|
||||
pulling those instead (#3995). -->
|
||||
<template v-if="p.subscribed_not_tracked.length">
|
||||
<div class="fc-recon__head">
|
||||
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
|
||||
@@ -36,37 +44,13 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bucket 2: REPORT ONLY. No disable control here by design — the
|
||||
source list on this same page is where that decision belongs. -->
|
||||
<template v-if="p.tracked_not_subscribed.length">
|
||||
<div class="fc-recon__head">
|
||||
<v-icon icon="mdi-help-circle-outline" size="small" class="me-2" />
|
||||
<strong>
|
||||
{{ p.tracked_not_subscribed.length }}
|
||||
{{ p.platform }}
|
||||
{{ p.tracked_not_subscribed.length === 1 ? 'source' : 'sources' }}
|
||||
your roster doesn't account for
|
||||
</strong>
|
||||
</div>
|
||||
<div v-for="s in p.tracked_not_subscribed" :key="s.id" class="fc-recon__row">
|
||||
<div class="fc-recon__body">
|
||||
<strong>{{ s.artist.name }}</strong>
|
||||
<div class="fc-recon__dim">{{ reasonFor(s) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-recon__dim fc-recon__note">
|
||||
Nothing has been changed. If you want one of these to stop checking,
|
||||
disable it in the list below.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- The roster could not be trusted, so bucket 2 was not computed. Said
|
||||
in words: an empty list here must never read as "all clear". -->
|
||||
<!-- The roster could not be trusted. Said in words: an empty list here
|
||||
must never read as "all clear". -->
|
||||
<div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
|
||||
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
|
||||
<template v-if="!p.last_success_at">
|
||||
Your {{ p.platform }} roster has never synced, so FC can't tell which
|
||||
sources you still subscribe to.
|
||||
creators you subscribe to.
|
||||
</template>
|
||||
<template v-else>
|
||||
Your {{ p.platform }} roster last synced
|
||||
@@ -81,44 +65,62 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
|
||||
|
||||
// #387 C4. Two directions, deliberately unequal: subscriptions FC doesn't
|
||||
// follow get a one-click add, while sources the roster doesn't account for are
|
||||
// REPORTED ONLY (operator decision, 2026-09-11). The asymmetry is the point —
|
||||
// adding a source is trivially undone, and "you no longer subscribe to this" is
|
||||
// computed from an absence that has three possible causes.
|
||||
// #387 C4. Offers the subscriptions FC doesn't follow yet. The other direction
|
||||
// (sources FC follows that the roster says you no longer pay for) used to be
|
||||
// listed here as a report. The operator didn't want it listed (2026-09-13); the
|
||||
// membership sweep stops pulling those sources instead (#3995).
|
||||
const store = useMembershipReconcileStore()
|
||||
|
||||
// An untrustworthy roster is only worth mentioning when there is something it
|
||||
// would have reconciled — otherwise a failed sweep on a platform with no
|
||||
// sources yet would put a warning on a page with nothing to warn about.
|
||||
const interesting = computed(() => store.platforms.filter(
|
||||
p => p.subscribed_not_tracked.length
|
||||
|| p.tracked_not_subscribed.length
|
||||
|| (!p.fresh && p.tracked_total)
|
||||
p => p.subscribed_not_tracked.length || (!p.fresh && p.tracked_total)
|
||||
))
|
||||
const anythingToSay = computed(() => interesting.value.length > 0)
|
||||
|
||||
function reasonFor (s) {
|
||||
if (s.basis === 'lapsed') {
|
||||
return `Your membership reads "${s.membership?.status}" — you're not a paying supporter of this creator right now.`
|
||||
}
|
||||
if (s.basis === 'absent_exact') {
|
||||
return "FC knows this creator's id on the platform, and it isn't in your roster."
|
||||
}
|
||||
// absent_handle — the weakest claim, and it says so. A renamed creator looks
|
||||
// exactly like this, which is why it is not phrased as a conclusion.
|
||||
return "No membership matched this source's address. It may simply have been renamed."
|
||||
// Dismissal is keyed to WHAT the card says, not to the card: a fingerprint of
|
||||
// the offered memberships and any stale-roster warnings. Dismissing hides this
|
||||
// exact set; a new subscription (or a roster going stale) changes the
|
||||
// fingerprint and brings the card back. Per-browser, which is enough for a
|
||||
// single-operator instance.
|
||||
const DISMISS_KEY = 'fc.recon.dismissed'
|
||||
const fingerprint = computed(() => interesting.value
|
||||
.flatMap(p => [
|
||||
...p.subscribed_not_tracked.map(m => `${p.platform}:${m.id}`),
|
||||
...(p.fresh ? [] : [`${p.platform}:stale`]),
|
||||
])
|
||||
.sort()
|
||||
.join('|'))
|
||||
|
||||
function readDismissed () {
|
||||
try { return localStorage.getItem(DISMISS_KEY) } catch { return null }
|
||||
}
|
||||
const dismissed = ref(readDismissed())
|
||||
|
||||
function dismiss () {
|
||||
dismissed.value = fingerprint.value
|
||||
try { localStorage.setItem(DISMISS_KEY, fingerprint.value) } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
const visible = computed(
|
||||
() => interesting.value.length > 0 && fingerprint.value !== dismissed.value,
|
||||
)
|
||||
|
||||
onMounted(() => { store.load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-recon { position: relative; }
|
||||
.fc-recon__dismiss {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
.fc-recon__platform + .fc-recon__platform {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 rail
|
||||
and day gutter (milestone #407). -->
|
||||
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. -->
|
||||
@@ -51,19 +51,20 @@
|
||||
</template>
|
||||
|
||||
<!-- Normal feed -->
|
||||
<div v-else class="fc-posts__layout">
|
||||
<!-- On a wide window this is a sticky left rail (#407 E); below the
|
||||
breakpoint it lays out exactly as the old inline header did. -->
|
||||
<aside class="fc-posts__rail">
|
||||
<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"
|
||||
/>
|
||||
<FeedStatusRibbon v-if="statusRibbon" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="fc-posts__main">
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
@@ -101,7 +102,6 @@
|
||||
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
@@ -313,44 +313,25 @@ onUnmounted(() => { teardownFeed(); teardownAround() })
|
||||
}
|
||||
.fc-posts__day-count { font-size: 0.78rem; }
|
||||
|
||||
/* Wide window (#407 D + E). The rail holds filters and status; 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). 1600px is where a 280px rail and a 150px gutter still leave a card
|
||||
wide enough to be worth the change. */
|
||||
/* 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: 2360px; }
|
||||
.fc-posts__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
.fc-posts__rail {
|
||||
position: sticky;
|
||||
top: calc(var(--fc-nav-h, 64px) + 16px);
|
||||
.fc-posts { max-width: 2080px; }
|
||||
.fc-posts__main { max-width: 2080px; margin: 0 auto; }
|
||||
.fc-posts__toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 24px;
|
||||
margin-left: calc(150px + 24px);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fc-posts__rail :deep(.fc-posts-filters) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.fc-posts__rail :deep(.fc-posts-filters__artist),
|
||||
.fc-posts__rail :deep(.fc-posts-filters__platform) {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.fc-posts__rail :deep(.fc-ribbon) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.fc-posts__main { max-width: 1900px; }
|
||||
/* 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);
|
||||
|
||||
Reference in New Issue
Block a user