start revive

This commit is contained in:
2026-07-30 21:25:03 -04:00
parent b7c4938c54
commit d8f11040bc
25 changed files with 5344 additions and 2559 deletions

View File

@@ -1,185 +1,353 @@
/**
* DiscoverPage component - Main discover/browse interface for PodTUI
* DiscoverPage — yazi-style 3-pane view.
*
* pane 0 (parent) — category list (the "containers")
* pane 1 (current) — podcast results for the focused category (landing pane)
* pane 2 (preview) — detail of the focused podcast + subscribe action
*
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
* panes; j/k move within; Enter subscribes to the focused podcast; r refreshes.
* Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering.
*/
import { createSignal, For, Show, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { PodcastCard } from "./PodcastCard";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Podcast } from "@/types/podcast";
import { PANE_RATIO } from "@/utils/navigation";
enum DiscoverPagePaneType {
CATEGORIES = 1,
SHOWS = 2,
export const DiscoverPaneCount = 3;
function DiscoverPage() {
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const CATS = PaneSlot.PARENT; // 0 — categories (parent)
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane)
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe
const categories = () => DISCOVER_CATEGORIES;
const podcasts = () => discoverStore.filteredPodcasts();
const focusedCategory = createMemo(() => {
const list = categories();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
});
// ── keep category + results focus in range ───────────────────────────────
const ensureFocus = () => {
const cl = categories();
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length)
nav.setFocusedIndex(CATS, cl.length - 1);
const pl = podcasts();
if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length)
nav.setFocusedIndex(RESULTS, pl.length - 1);
};
onMount(ensureFocus);
const focusedPodcast = createMemo(() => {
const list = podcasts();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)];
});
// Register a resolver so visual-mode range selection grows by podcast id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
});
onCleanup(() => unsub());
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleSubscribe = (podcast: Podcast) => {
discoverStore.toggleSubscription(podcast.id);
};
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
nav.swipe(1, DiscoverPaneCount); // dive to results
return;
}
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) handleSubscribe(pod);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id);
}
},
refresh: () => {
discoverStore.refresh().catch(() => {});
},
};
function len(pane: PaneId): number {
if (pane === CATS) return categories().length;
if (pane === RESULTS) return podcasts().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
if (pane === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
}
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): categories ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Categories</text>
</box>
<scrollbox
height="100%"
focused={isActive(CATS)}
border
borderColor={border(CATS)}
backgroundColor={theme.background}
>
<For each={categories()}>
{(cat, index) => {
const selected = () =>
cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={
selected() && !isActive(CATS)
? theme.border
: focusBg(index(), CATS)
}
onMouseDown={() => {
nav.setActivePane(CATS);
nav.setFocusedIndex(CATS, index());
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), CATS)}>
{index() === nav.focusedIndex(CATS) ? "" : " "}
</text>
<text fg={focusFg(index(), CATS)}>{cat.name}</text>
<Show when={selected()}>
<text
fg={
index() === nav.focusedIndex(CATS)
? theme.surface
: theme.accent
}
>
*
</text>
</Show>
</box>
);
}}
</For>
</scrollbox>
</box>
{/* ── pane 1 (current, center): results ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{focusedCategory()?.name ?? "Discover"} · {podcasts().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
<Show when={podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREVIEW)}
border
borderColor={border(PREVIEW)}
backgroundColor={theme.background}
>
<Show
when={focusedPodcast()}
fallback={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>
Updated: {formatDate(pod().lastUpdated)}
</text>
<box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes r: refresh</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
}
export const DiscoverPaneCount = 2;
export function DiscoverPage() {
const discoverStore = useDiscoverStore();
const [showIndex, setShowIndex] = createSignal(0);
const [categoryIndex, setCategoryIndex] = createSignal(0);
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
setShowIndex(showIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return;
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length === 0) return;
if (isDown && !isInverting()) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if (isUp && isInverting()) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
}
},
{ release: false },
);
});
const handleCategorySelect = (categoryId: string) => {
discoverStore.setSelectedCategory(categoryId);
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
if (index >= 0) setCategoryIndex(index);
setShowIndex(0);
};
const handleShowSelect = (index: number) => {
setShowIndex(index);
};
const handleSubscribe = (podcast: { id: string }) => {
discoverStore.toggleSubscription(podcast.id);
};
const { theme } = useTheme();
return (
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
<box
border
padding={1}
borderColor={
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
? theme.border
: theme.accent
}
flexDirection="column"
gap={1}
>
<text
fg={
nav.activeDepth() == DiscoverPagePaneType.CATEGORIES
? theme.accent
: theme.text
}
>
Categories:
</text>
<box flexDirection="column" gap={1}>
<For each={discoverStore.categories}>
{(category) => {
const isSelected = () =>
discoverStore.selectedCategory() === category.id;
return (
<SelectableBox
selected={isSelected}
onMouseDown={() => handleCategorySelect(category.id)}
>
<SelectableText selected={isSelected} primary>
{category.icon} {category.name}
</SelectableText>
</SelectableBox>
);
}}
</For>
</box>
</box>
<box
flexDirection="column"
flexGrow={1}
border
borderColor={
nav.activeDepth() == DiscoverPagePaneType.SHOWS
? theme.accent
: theme.border
}
>
<box padding={1}>
<SelectableText
selected={() => false}
primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
Trending in{" "}
{DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory(),
)?.name ?? "All"}
</SelectableText>
</box>
<box flexDirection="column" height="100%">
<Show
fallback={
<box padding={2}>
{discoverStore.filteredPodcasts().length !== 0 ? (
<text fg={theme.warning}>Loading trending shows...</text>
) : (
<text fg={theme.textMuted}>
No podcasts found in this category.
</text>
)}
</box>
}
when={
!discoverStore.isLoading() &&
discoverStore.filteredPodcasts().length === 0
}
>
<scrollbox
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
<box flexDirection="column">
<For each={discoverStore.filteredPodcasts()}>
{(podcast, index) => (
<PodcastCard
podcast={podcast}
selected={
index() === showIndex() &&
nav.activeDepth() == DiscoverPagePaneType.SHOWS
}
onSelect={() => handleShowSelect(index())}
onSubscribe={() => handleSubscribe(podcast)}
/>
)}
</For>
</box>
</scrollbox>
</Show>
</box>
</box>
</box>
);
}
export { DiscoverPage };