What you pay for, what Discord drops, and pixiv switched off #251
@@ -235,6 +235,15 @@ async def scheduler_status(session: AsyncSession) -> dict:
|
|||||||
select(func.count()).select_from(Source)
|
select(func.count()).select_from(Source)
|
||||||
.where(Source.enabled.is_(True), no_access_sources_clause())
|
.where(Source.enabled.is_(True), no_access_sources_clause())
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
|
# #387 B4: lets the front door tell "nothing configured yet" (a fresh
|
||||||
|
# install — show the on-ramp) apart from "configured, still fetching" (a
|
||||||
|
# first run in progress — show what's running). Telling someone to add a
|
||||||
|
# source when they already have three and are mid-backfill is worse than
|
||||||
|
# saying nothing. Deliberately NOT auto_sources, which counts only what is
|
||||||
|
# on a schedule: a source with auto_check off still means "configured".
|
||||||
|
total_sources = (await session.execute(
|
||||||
|
select(func.count()).select_from(Source).where(Source.enabled.is_(True))
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"last_tick_at": last_tick_at,
|
"last_tick_at": last_tick_at,
|
||||||
@@ -243,5 +252,6 @@ async def scheduler_status(session: AsyncSession) -> dict:
|
|||||||
"auto_sources": len(rows),
|
"auto_sources": len(rows),
|
||||||
"failing_sources": failing_sources,
|
"failing_sources": failing_sources,
|
||||||
"no_access_sources": no_access_sources,
|
"no_access_sources": no_access_sources,
|
||||||
|
"total_sources": total_sources,
|
||||||
"platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()},
|
"platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-empty">
|
||||||
|
<!-- The brand mark's second home (#387 A-side logo work, operator-confirmed).
|
||||||
|
It earns its place HERE and not on a populated feed: this is the first
|
||||||
|
screen a fresh install shows anyone, and it gives the on-ramp something
|
||||||
|
to be composed around rather than a bare line of prose plus buttons.
|
||||||
|
Large on purpose — logo.svg stops reading below ~48px, which is why the
|
||||||
|
22px nav slot has its own mark. No wrapping card: the file deliberately
|
||||||
|
carries no plate and is meant to sit on the page surface. Vendored
|
||||||
|
locally, so this renders on an install with no network — which is
|
||||||
|
exactly the state this screen appears in. -->
|
||||||
|
<img src="/logo.svg" alt="" class="fc-empty__mark" width="150" height="150" />
|
||||||
|
|
||||||
|
<!-- FIRST RUN: sources exist, nothing has landed yet. Telling someone to
|
||||||
|
add a source when they already have three and are mid-backfill is
|
||||||
|
worse than saying nothing at all. -->
|
||||||
|
<template v-if="hasSources">
|
||||||
|
<p class="fc-empty__lead">Nothing has arrived yet.</p>
|
||||||
|
<p class="fc-empty__sub">
|
||||||
|
{{ sourceCountLabel }} configured. The first check can take a while —
|
||||||
|
deep history is fetched in chunks.
|
||||||
|
</p>
|
||||||
|
<div class="fc-empty__actions">
|
||||||
|
<v-btn
|
||||||
|
size="small" variant="tonal" color="accent"
|
||||||
|
prepend-icon="mdi-progress-download"
|
||||||
|
:to="{ path: '/subscriptions', query: { tab: 'downloads' } }"
|
||||||
|
>See what's running</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- FRESH INSTALL: the on-ramp, in the order the steps actually depend on
|
||||||
|
each other — a source cannot fetch anything without a credential. -->
|
||||||
|
<template v-else>
|
||||||
|
<p class="fc-empty__lead">Nothing here yet.</p>
|
||||||
|
<p class="fc-empty__sub">
|
||||||
|
FabledCurator follows the creators you subscribe to and files what they
|
||||||
|
post. Two steps to start.
|
||||||
|
</p>
|
||||||
|
<div class="fc-empty__actions">
|
||||||
|
<v-btn
|
||||||
|
size="small" variant="tonal" color="accent" prepend-icon="mdi-key-variant"
|
||||||
|
:to="{ path: '/subscriptions', query: { tab: 'settings' } }"
|
||||||
|
>Add a credential</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small" variant="text" prepend-icon="mdi-plus"
|
||||||
|
:to="{ path: '/subscriptions' }"
|
||||||
|
>Add a source</v-btn>
|
||||||
|
</div>
|
||||||
|
<p class="fc-empty__hint">
|
||||||
|
A credential comes first — a source can't fetch anything without your
|
||||||
|
logged-in session.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
import { useSourcesStore } from '../../stores/sources.js'
|
||||||
|
|
||||||
|
const store = useSourcesStore()
|
||||||
|
const { scheduleStatus: status } = storeToRefs(store)
|
||||||
|
|
||||||
|
// Absent status is treated as "fresh install", which is the safe way round:
|
||||||
|
// the on-ramp is useful to a first-run operator and merely redundant to an
|
||||||
|
// established one, whereas "see what's running" shown to someone with nothing
|
||||||
|
// configured is a dead end.
|
||||||
|
const hasSources = computed(() => (status.value?.total_sources || 0) > 0)
|
||||||
|
|
||||||
|
const sourceCountLabel = computed(() => {
|
||||||
|
const n = status.value?.total_sources || 0
|
||||||
|
return `${n} ${n === 1 ? 'source' : 'sources'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// The ribbon may already have loaded this on the front door; in Browse's
|
||||||
|
// Posts tab nothing has. Failure is swallowed — an empty feed must still
|
||||||
|
// explain itself when the status call is unavailable (rule #164).
|
||||||
|
if (!status.value) store.loadScheduleStatus().catch(() => {})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 48px 16px 32px;
|
||||||
|
}
|
||||||
|
.fc-empty__mark {
|
||||||
|
/* Sits back a little: it frames the message rather than competing with it. */
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fc-empty__lead {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-empty__sub, .fc-empty__hint {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
.fc-empty__hint { font-size: 0.8rem; margin-top: 4px; }
|
||||||
|
.fc-empty__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -66,11 +66,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
|
<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>
|
<p v-if="hasActiveFilter">No posts match your search or filters.</p>
|
||||||
<p v-else>No posts yet. Subscribe to a source on the
|
<FeedEmptyState v-else />
|
||||||
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
|
||||||
tab to start capturing posts.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -92,6 +92,7 @@ import { usePostsStore } from '../stores/posts.js'
|
|||||||
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
|
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
|
||||||
import PostCard from '../components/posts/PostCard.vue'
|
import PostCard from '../components/posts/PostCard.vue'
|
||||||
import FeedStatusRibbon from '../components/posts/FeedStatusRibbon.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 —
|
// 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
|
// inside Browse's Posts tab you are looking FOR something, and the Subscriptions
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
import FeedEmptyState from '../../src/components/posts/FeedEmptyState.vue'
|
||||||
|
import { useSourcesStore } from '../../src/stores/sources.js'
|
||||||
|
import { mountWithStore } from '../support/mountComponent.js'
|
||||||
|
|
||||||
|
// #387 B4. This is the first screen a fresh install shows anyone, so the thing
|
||||||
|
// worth pinning is that it tells the two empties apart. Telling an operator to
|
||||||
|
// "add a source" when they already have three and are mid-backfill is worse
|
||||||
|
// than saying nothing — it reads as the app not knowing its own state.
|
||||||
|
|
||||||
|
const mountWith = (status) => mountWithStore(FeedEmptyState, () => {
|
||||||
|
useSourcesStore().scheduleStatus = status
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FeedEmptyState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.fetch = vi.fn(async () => { throw new Error('offline') })
|
||||||
|
})
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it('a fresh install gets the on-ramp, credential first', () => {
|
||||||
|
const w = mountWith({ total_sources: 0 })
|
||||||
|
expect(w.text()).toContain('Add a credential')
|
||||||
|
expect(w.text()).toContain('Add a source')
|
||||||
|
expect(w.text()).not.toContain("See what's running")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an install that is already fetching is told so, not told to set up', () => {
|
||||||
|
const w = mountWith({ total_sources: 3 })
|
||||||
|
expect(w.text()).toContain('3 sources')
|
||||||
|
expect(w.text()).toContain("See what's running")
|
||||||
|
expect(w.text()).not.toContain('Add a credential')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('singularises a lone source', () => {
|
||||||
|
const w = mountWith({ total_sources: 1 })
|
||||||
|
expect(w.text()).toContain('1 source')
|
||||||
|
expect(w.text()).not.toContain('1 sources')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the on-ramp when the status call never answered', () => {
|
||||||
|
// Safe direction: the on-ramp is merely redundant to an established
|
||||||
|
// operator, whereas "see what's running" shown to someone with nothing
|
||||||
|
// configured is a dead end.
|
||||||
|
const w = mountWith(null)
|
||||||
|
expect(w.text()).toContain('Add a credential')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the brand mark, sized to be readable', () => {
|
||||||
|
// logo.svg stops reading below ~48px — that is why the 22px nav slot has a
|
||||||
|
// different mark. If this ever shrinks to a glyph, it is the wrong asset.
|
||||||
|
const img = mountWith({ total_sources: 0 }).find('img.fc-empty__mark')
|
||||||
|
expect(img.exists()).toBe(true)
|
||||||
|
expect(img.attributes('src')).toBe('/logo.svg')
|
||||||
|
expect(Number(img.attributes('width'))).toBeGreaterThanOrEqual(48)
|
||||||
|
// Decorative: the surrounding prose already carries the meaning.
|
||||||
|
expect(img.attributes('alt')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|||||||
|
|
||||||
import FeedStatusRibbon from '../../src/components/posts/FeedStatusRibbon.vue'
|
import FeedStatusRibbon from '../../src/components/posts/FeedStatusRibbon.vue'
|
||||||
import { useSourcesStore } from '../../src/stores/sources.js'
|
import { useSourcesStore } from '../../src/stores/sources.js'
|
||||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
import { mountWithStore } from '../support/mountComponent.js'
|
||||||
|
|
||||||
// #387 B3. This ribbon is the ONLY place phase A's work reaches someone who
|
// #387 B3. This ribbon is the ONLY place phase A's work reaches someone who
|
||||||
// wasn't already looking for it, so what it does and does not say is the whole
|
// wasn't already looking for it, so what it does and does not say is the whole
|
||||||
@@ -12,11 +12,9 @@ import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
|||||||
// zero — a "0 failing" on the front door is noise that trains you to ignore
|
// zero — a "0 failing" on the front door is noise that trains you to ignore
|
||||||
// the line, which is exactly what would hide the real number later.
|
// the line, which is exactly what would hide the real number later.
|
||||||
|
|
||||||
function mountWith (status) {
|
const mountWith = (status) => mountWithStore(FeedStatusRibbon, () => {
|
||||||
const pinia = freshPinia()
|
|
||||||
useSourcesStore().scheduleStatus = status
|
useSourcesStore().scheduleStatus = status
|
||||||
return mountComponent(FeedStatusRibbon, { pinia })
|
})
|
||||||
}
|
|
||||||
|
|
||||||
describe('FeedStatusRibbon', () => {
|
describe('FeedStatusRibbon', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -35,3 +35,13 @@ export const VTooltipStub = {
|
|||||||
name: 'VTooltip',
|
name: 'VTooltip',
|
||||||
template: '<div><slot name="activator" :props="{}" /><slot /></div>',
|
template: '<div><slot name="activator" :props="{}" /><slot /></div>',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mount with a fresh pinia and the store already seeded, for components that
|
||||||
|
// read store state during render. Without it, the seeding has to be inlined
|
||||||
|
// between createPinia and mount in every spec — the same copy-paste that issue
|
||||||
|
// #3109 tracks for the backend row factories.
|
||||||
|
export function mountWithStore (Component, seed, opts = {}) {
|
||||||
|
const pinia = freshPinia()
|
||||||
|
seed()
|
||||||
|
return mountComponent(Component, { ...opts, pinia })
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async def test_schedule_status_shape(client):
|
|||||||
body = await resp.get_json()
|
body = await resp.get_json()
|
||||||
assert set(body) == {
|
assert set(body) == {
|
||||||
"last_tick_at", "next_due_at", "due_now", "auto_sources",
|
"last_tick_at", "next_due_at", "due_now", "auto_sources",
|
||||||
"failing_sources", "no_access_sources",
|
"failing_sources", "no_access_sources", "total_sources",
|
||||||
"platform_cooldowns",
|
"platform_cooldowns",
|
||||||
}
|
}
|
||||||
assert isinstance(body["due_now"], int)
|
assert isinstance(body["due_now"], int)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async def test_summary_returns_rollup_shape(client, monkeypatch):
|
|||||||
assert isinstance(body["failing"], int)
|
assert isinstance(body["failing"], int)
|
||||||
assert set(body["scheduler"]) == {
|
assert set(body["scheduler"]) == {
|
||||||
"last_tick_at", "next_due_at", "due_now", "auto_sources",
|
"last_tick_at", "next_due_at", "due_now", "auto_sources",
|
||||||
"failing_sources", "no_access_sources",
|
"failing_sources", "no_access_sources", "total_sources",
|
||||||
"platform_cooldowns",
|
"platform_cooldowns",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user