feat: labeled loading spinners + [Fetch More] pagination on the feed list

Loading indicators: the braille spinner now carries a contextual label
(Refreshing…, Fetching…, Loading more…, Discovering…, Searching…) and is
shown in every loading state that previously rendered nothing — Discover
results, Search results fallback, and the empty Feed list.

Feed pagination: a focusable "[Fetch More]" row at the bottom of the flat
feed list advances every feed's loaded window by 50 episodes via the new
loadMoreAllFeeds/hasMoreAcrossAll store API. Behavior is a setting
(Fetch More: manual|auto, default manual) persisted in config.json; auto
fetches when focus reaches the bottom row. The button row is excluded
from episode focus so no episode is double-highlighted while it is active.
This commit is contained in:
2026-08-10 10:38:20 -04:00
parent 2e69868ffc
commit 4a94ff5910
10 changed files with 274 additions and 106 deletions

View File

@@ -1,23 +1,30 @@
import { createSignal, createMemo, onCleanup } from "solid-js";
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export function LoadingIndicator() {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);
/**
* Animated braille spinner with an optional label (e.g. "Refreshing…").
* The spinner is rendered in the theme primary color; the label in muted.
*/
export function LoadingIndicator(props: { label?: string }) {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);
const interval = setInterval(() => {
setIndex((i) => (i + 1) % spinnerChars.length);
}, 65);
const interval = setInterval(() => {
setIndex((i) => (i + 1) % spinnerChars.length);
}, 65);
onCleanup(() => clearInterval(interval));
onCleanup(() => clearInterval(interval));
const currentChar = createMemo(() => spinnerChars[index()]);
const currentChar = createMemo(() => spinnerChars[index()]);
return (
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start">
<text fg={theme.primary} content={currentChar()} />
</box>
);
return (
<box flexDirection="row" gap={1} alignItems="flex-start">
<text fg={theme.primary} content={currentChar()} />
<Show when={props.label}>
<text fg={theme.muted || theme.text} content={props.label} />
</Show>
</box>
);
}