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)
|
||||
.where(Source.enabled.is_(True), no_access_sources_clause())
|
||||
)).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 {
|
||||
"last_tick_at": last_tick_at,
|
||||
@@ -243,5 +252,6 @@ async def scheduler_status(session: AsyncSession) -> dict:
|
||||
"auto_sources": len(rows),
|
||||
"failing_sources": failing_sources,
|
||||
"no_access_sources": no_access_sources,
|
||||
"total_sources": total_sources,
|
||||
"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 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-else>No posts yet. Subscribe to a source on the
|
||||
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
||||
tab to start capturing posts.
|
||||
</p>
|
||||
<FeedEmptyState v-else />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
@@ -92,6 +92,7 @@ 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
|
||||
|
||||
@@ -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 { 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
|
||||
// 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
|
||||
// the line, which is exactly what would hide the real number later.
|
||||
|
||||
function mountWith (status) {
|
||||
const pinia = freshPinia()
|
||||
const mountWith = (status) => mountWithStore(FeedStatusRibbon, () => {
|
||||
useSourcesStore().scheduleStatus = status
|
||||
return mountComponent(FeedStatusRibbon, { pinia })
|
||||
}
|
||||
})
|
||||
|
||||
describe('FeedStatusRibbon', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -35,3 +35,13 @@ export const VTooltipStub = {
|
||||
name: 'VTooltip',
|
||||
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()
|
||||
assert set(body) == {
|
||||
"last_tick_at", "next_due_at", "due_now", "auto_sources",
|
||||
"failing_sources", "no_access_sources",
|
||||
"failing_sources", "no_access_sources", "total_sources",
|
||||
"platform_cooldowns",
|
||||
}
|
||||
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 set(body["scheduler"]) == {
|
||||
"last_tick_at", "next_due_at", "due_now", "auto_sources",
|
||||
"failing_sources", "no_access_sources",
|
||||
"failing_sources", "no_access_sources", "total_sources",
|
||||
"platform_cooldowns",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user