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>
|
<template>
|
||||||
<!-- Renders nothing when there is nothing to say — same posture as
|
<!-- Renders nothing when there is nothing to say — same posture as
|
||||||
NeedsAttentionCard. A reconciliation card that always shows would train
|
NeedsAttentionCard. A reconciliation card that always shows would train
|
||||||
the operator to scroll past it. -->
|
the operator to scroll past it. It can also be DISMISSED, and stays
|
||||||
<v-card v-if="anythingToSay" variant="tonal" class="mb-4 fc-recon">
|
dismissed until what it would say changes. -->
|
||||||
|
<v-card v-if="visible" variant="tonal" class="mb-4 fc-recon">
|
||||||
<v-card-text>
|
<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">
|
<div v-for="p in interesting" :key="p.platform" class="fc-recon__platform">
|
||||||
<!-- Bucket 1: the adoption win. The only direction with an action,
|
<!-- The adoption win: subscriptions FC doesn't follow yet. The only
|
||||||
because adding a source is the reversible half. -->
|
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">
|
<template v-if="p.subscribed_not_tracked.length">
|
||||||
<div class="fc-recon__head">
|
<div class="fc-recon__head">
|
||||||
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
|
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
|
||||||
@@ -36,37 +44,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Bucket 2: REPORT ONLY. No disable control here by design — the
|
<!-- The roster could not be trusted. Said in words: an empty list here
|
||||||
source list on this same page is where that decision belongs. -->
|
must never read as "all clear". -->
|
||||||
<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". -->
|
|
||||||
<div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
|
<div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
|
||||||
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
|
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
|
||||||
<template v-if="!p.last_success_at">
|
<template v-if="!p.last_success_at">
|
||||||
Your {{ p.platform }} roster has never synced, so FC can't tell which
|
Your {{ p.platform }} roster has never synced, so FC can't tell which
|
||||||
sources you still subscribe to.
|
creators you subscribe to.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
Your {{ p.platform }} roster last synced
|
Your {{ p.platform }} roster last synced
|
||||||
@@ -81,44 +65,62 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import { formatRelative } from '../../utils/date.js'
|
import { formatRelative } from '../../utils/date.js'
|
||||||
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
|
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
|
||||||
|
|
||||||
// #387 C4. Two directions, deliberately unequal: subscriptions FC doesn't
|
// #387 C4. Offers the subscriptions FC doesn't follow yet. The other direction
|
||||||
// follow get a one-click add, while sources the roster doesn't account for are
|
// (sources FC follows that the roster says you no longer pay for) used to be
|
||||||
// REPORTED ONLY (operator decision, 2026-09-11). The asymmetry is the point —
|
// listed here as a report. The operator didn't want it listed (2026-09-13); the
|
||||||
// adding a source is trivially undone, and "you no longer subscribe to this" is
|
// membership sweep stops pulling those sources instead (#3995).
|
||||||
// computed from an absence that has three possible causes.
|
|
||||||
const store = useMembershipReconcileStore()
|
const store = useMembershipReconcileStore()
|
||||||
|
|
||||||
// An untrustworthy roster is only worth mentioning when there is something it
|
// An untrustworthy roster is only worth mentioning when there is something it
|
||||||
// would have reconciled — otherwise a failed sweep on a platform with no
|
// 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.
|
// sources yet would put a warning on a page with nothing to warn about.
|
||||||
const interesting = computed(() => store.platforms.filter(
|
const interesting = computed(() => store.platforms.filter(
|
||||||
p => p.subscribed_not_tracked.length
|
p => p.subscribed_not_tracked.length || (!p.fresh && p.tracked_total)
|
||||||
|| p.tracked_not_subscribed.length
|
|
||||||
|| (!p.fresh && p.tracked_total)
|
|
||||||
))
|
))
|
||||||
const anythingToSay = computed(() => interesting.value.length > 0)
|
|
||||||
|
|
||||||
function reasonFor (s) {
|
// Dismissal is keyed to WHAT the card says, not to the card: a fingerprint of
|
||||||
if (s.basis === 'lapsed') {
|
// the offered memberships and any stale-roster warnings. Dismissing hides this
|
||||||
return `Your membership reads "${s.membership?.status}" — you're not a paying supporter of this creator right now.`
|
// 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
|
||||||
if (s.basis === 'absent_exact') {
|
// single-operator instance.
|
||||||
return "FC knows this creator's id on the platform, and it isn't in your roster."
|
const DISMISS_KEY = 'fc.recon.dismissed'
|
||||||
}
|
const fingerprint = computed(() => interesting.value
|
||||||
// absent_handle — the weakest claim, and it says so. A renamed creator looks
|
.flatMap(p => [
|
||||||
// exactly like this, which is why it is not phrased as a conclusion.
|
...p.subscribed_not_tracked.map(m => `${p.platform}:${m.id}`),
|
||||||
return "No membership matched this source's address. It may simply have been renamed."
|
...(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() })
|
onMounted(() => { store.load() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.fc-recon { position: relative; }
|
||||||
|
.fc-recon__dismiss {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
.fc-recon__platform + .fc-recon__platform {
|
.fc-recon__platform + .fc-recon__platform {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Width is set in CSS, not with `max-width` here: below 1600px it is
|
<!-- 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
|
today's 900px column, and above it the feed widens and gains the day
|
||||||
and day gutter (milestone #407). -->
|
gutter (milestone #407). -->
|
||||||
<v-container fluid class="pt-2 pb-6 fc-posts">
|
<v-container fluid class="pt-2 pb-6 fc-posts">
|
||||||
<!-- In-context view: deep-linked to one post, with bidirectional infinite
|
<!-- In-context view: deep-linked to one post, with bidirectional infinite
|
||||||
scroll — newer posts load above, older posts below. -->
|
scroll — newer posts load above, older posts below. -->
|
||||||
@@ -51,19 +51,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Normal feed -->
|
<!-- Normal feed -->
|
||||||
<div v-else class="fc-posts__layout">
|
<div v-else class="fc-posts__main">
|
||||||
<!-- On a wide window this is a sticky left rail (#407 E); below the
|
<!-- Filters and status sit in one row above the feed. #407 option E put
|
||||||
breakpoint it lays out exactly as the old inline header did. -->
|
them in a sticky left rail; the operator found it wasted space
|
||||||
<aside class="fc-posts__rail">
|
(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
|
<PostsFilterBar
|
||||||
:artist-id="artistFilter"
|
:artist-id="artistFilter"
|
||||||
:platform="platformFilter"
|
:platform="platformFilter"
|
||||||
@update:filters="onFilters"
|
@update:filters="onFilters"
|
||||||
/>
|
/>
|
||||||
<FeedStatusRibbon v-if="statusRibbon" />
|
</div>
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="fc-posts__main">
|
|
||||||
<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>
|
||||||
@@ -101,7 +102,6 @@
|
|||||||
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
@@ -313,44 +313,25 @@ onUnmounted(() => { teardownFeed(); teardownAround() })
|
|||||||
}
|
}
|
||||||
.fc-posts__day-count { font-size: 0.78rem; }
|
.fc-posts__day-count { font-size: 0.78rem; }
|
||||||
|
|
||||||
/* Wide window (#407 D + E). The rail holds filters and status; each day's
|
/* Wide window (#407 A + D). Each day's heading moves into a sticky gutter
|
||||||
heading moves into a sticky gutter beside its posts; the column widens and
|
beside its posts; the column widens and the cards switch to their filmstrip
|
||||||
the cards switch to their filmstrip layout on their own (PostCard measures
|
layout on their own (PostCard measures itself). The toolbar lines up with the
|
||||||
itself). 1600px is where a 280px rail and a 150px gutter still leave a card
|
feed column, clear of the gutter. */
|
||||||
wide enough to be worth the change. */
|
|
||||||
@media (min-width: 1600px) {
|
@media (min-width: 1600px) {
|
||||||
.fc-posts { max-width: 2360px; }
|
.fc-posts { max-width: 2080px; }
|
||||||
.fc-posts__layout {
|
.fc-posts__main { max-width: 2080px; margin: 0 auto; }
|
||||||
display: grid;
|
.fc-posts__toolbar {
|
||||||
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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 16px;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 24px;
|
||||||
|
margin-left: calc(150px + 24px);
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.fc-posts__rail :deep(.fc-posts-filters) {
|
/* Status reads after the filters on a wide row, but stays first when the
|
||||||
flex-direction: column;
|
narrow layout stacks them (it is the front door's headline). */
|
||||||
align-items: stretch;
|
.fc-posts__status { order: 2; padding: 0; }
|
||||||
padding-bottom: 0;
|
.fc-posts__toolbar :deep(.fc-posts-filters) { 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; }
|
|
||||||
.fc-posts__day {
|
.fc-posts__day {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 150px minmax(0, 1fr);
|
grid-template-columns: 150px minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user