388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
/**
|
||
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||
*
|
||
* depth 0 (current) — category list. Parent pane shows the muted
|
||
* placeholder (1/5 slot kept).
|
||
* depth 1 (current) — podcast results for the drilled category. Parent
|
||
* pane = the categories list.
|
||
* preview — detail of the hovered item (category summary, or
|
||
* podcast detail + subscribe action).
|
||
*
|
||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||
* column. Moving through categories at depth 0 updates the store's selected
|
||
* category so the preview follows.
|
||
*/
|
||
|
||
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 {
|
||
useNavigation,
|
||
NavMode,
|
||
DEPTH_CENTER_PANE,
|
||
type PaneId,
|
||
type DepthFrame,
|
||
} from "@/context/NavigationContext";
|
||
import { on, off } from "@/utils/event-bus";
|
||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||
import { PaneRow } from "@/components/PaneRow";
|
||
import { TabListPane } from "@/components/TabPanel";
|
||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||
|
||
export const DiscoverPaneCount = 1;
|
||
|
||
function DiscoverPage() {
|
||
const discoverStore = useDiscoverStore();
|
||
const { theme } = useTheme();
|
||
const muted = () => theme.muted || theme.text;
|
||
const nav = useNavigation();
|
||
|
||
const depth = nav.currentDepth;
|
||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||
|
||
const categories = () => DISCOVER_CATEGORIES;
|
||
const podcasts = () => discoverStore.filteredPodcasts();
|
||
|
||
const focusedCatIdx = () =>
|
||
categories().length === 0 ? 0 : Math.min(focus(0), categories().length - 1);
|
||
const focusedCategory = createMemo(() => categories()[focusedCatIdx()]);
|
||
|
||
const focusedPodIdx = () =>
|
||
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
||
|
||
const curLen = () =>
|
||
depth() === 0 ? categories().length : podcasts().length;
|
||
|
||
const ensureFocus = () => {
|
||
if (categories().length > 0 && focus(0) >= categories().length)
|
||
nav.setDepthFocus(categories().length - 1, 0);
|
||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||
nav.setDepthFocus(podcasts().length - 1, 1);
|
||
};
|
||
onMount(ensureFocus);
|
||
|
||
// Auto-fetch the featured-shows manifest on first mount (network failure is
|
||
// non-fatal — the list stays empty until the user hits refresh).
|
||
onMount(() => {
|
||
discoverStore.refresh().catch(() => {});
|
||
});
|
||
|
||
onMount(() => {
|
||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||
if (depth() === 0) return categories()[i]?.id;
|
||
return podcasts()[i]?.id;
|
||
});
|
||
});
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────────
|
||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||
|
||
// ── drill / open ───────────────────────────────────────────────────────────
|
||
function open() {
|
||
if (depth() === 0) {
|
||
const c = focusedCategory();
|
||
if (!c) return;
|
||
discoverStore.setSelectedCategory(c.id);
|
||
nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame);
|
||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||
return;
|
||
}
|
||
if (depth() >= 1) {
|
||
const pod = focusedPodcast();
|
||
if (pod) discoverStore.toggleSubscription(pod.id);
|
||
}
|
||
}
|
||
|
||
// ── nav.action handler ────────────────────────────────────────────────────
|
||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||
"move-down": () => step(1),
|
||
"move-up": () => step(-1),
|
||
"jump-down": () => step(5),
|
||
"jump-up": () => step(-5),
|
||
"page-down": () => step(10),
|
||
"page-up": () => step(-10),
|
||
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||
open: () => open(),
|
||
"toggle-select": () => {
|
||
if (depth() >= 1) {
|
||
const pod = focusedPodcast();
|
||
if (pod) nav.toggleSelected(pod.id);
|
||
}
|
||
},
|
||
refresh: () => {
|
||
discoverStore.refresh().catch(() => {});
|
||
},
|
||
};
|
||
function step(delta: number) {
|
||
nav.move(delta, curLen());
|
||
// keep the store's selected category synced with the focused row at depth 0
|
||
if (depth() === 0) {
|
||
const c = focusedCategory();
|
||
if (c) discoverStore.setSelectedCategory(c.id);
|
||
}
|
||
}
|
||
const onAction = (data: {
|
||
action: KeybindActionName;
|
||
pane: PaneId;
|
||
mode: NavMode;
|
||
}) => {
|
||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||
ensureFocus();
|
||
PAGE_ACTIONS[data.action]?.();
|
||
};
|
||
onMount(() => {
|
||
on("nav.action", onAction);
|
||
onCleanup(() => off("nav.action", onAction));
|
||
});
|
||
|
||
// ── render ──────────────────────────────────────────────────────────────────
|
||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||
i === lf && active
|
||
? theme.surface
|
||
: i === lf
|
||
? theme.selectedListItemText ?? theme.text
|
||
: theme.text;
|
||
|
||
const currentLabel = () =>
|
||
depth() === 0
|
||
? "Categories"
|
||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||
|
||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||
// mounts/unmounts cleanly on depth change.
|
||
const parentContent = () => (
|
||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||
<For each={categories()}>
|
||
{(cat, index) => {
|
||
const lf = () => nav.depthFocus(0);
|
||
const ref = useScrollIntoView(() => index() === lf());
|
||
return (
|
||
<box
|
||
ref={ref}
|
||
flexDirection="row"
|
||
gap={1}
|
||
paddingLeft={1}
|
||
paddingRight={1}
|
||
backgroundColor={focusBg(index(), lf(), false)}
|
||
>
|
||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||
</text>
|
||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||
{cat.name}
|
||
</text>
|
||
</box>
|
||
);
|
||
}}
|
||
</For>
|
||
</Show>
|
||
);
|
||
|
||
// ── current pane ───────────────────────────────────────────────────────────
|
||
const currentContent = () => (
|
||
<>
|
||
{/* depth 0: categories */}
|
||
<Show when={depth() === 0}>
|
||
<For each={categories()}>
|
||
{(cat, index) => {
|
||
const lf = () => focusedCatIdx();
|
||
const ref = useScrollIntoView(() => index() === lf());
|
||
return (
|
||
<box
|
||
ref={ref}
|
||
flexDirection="row"
|
||
gap={1}
|
||
paddingLeft={1}
|
||
paddingRight={1}
|
||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||
onMouseDown={() => {
|
||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||
nav.setDepthFocus(index(), 0);
|
||
discoverStore.setSelectedCategory(cat.id);
|
||
}}
|
||
>
|
||
<text fg={focusFg(index(), lf(), isActive())}>
|
||
{index() === lf() ? "❯" : " "}
|
||
</text>
|
||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||
</box>
|
||
);
|
||
}}
|
||
</For>
|
||
</Show>
|
||
{/* depth ≥1: results */}
|
||
<Show when={depth() >= 1}>
|
||
<Show
|
||
when={podcasts().length > 0}
|
||
fallback={
|
||
<box padding={1}>
|
||
<text fg={muted()}>No podcasts found. :refresh</text>
|
||
</box>
|
||
}
|
||
>
|
||
<For each={podcasts()}>
|
||
{(podcast, index) => {
|
||
const lf = () => focusedPodIdx();
|
||
const ref = useScrollIntoView(() => index() === lf());
|
||
return (
|
||
<box
|
||
ref={ref}
|
||
flexDirection="column"
|
||
gap={0}
|
||
paddingLeft={1}
|
||
paddingRight={1}
|
||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||
onMouseDown={() => {
|
||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||
nav.setDepthFocus(index(), 1);
|
||
}}
|
||
>
|
||
<box flexDirection="row" gap={1}>
|
||
<text fg={focusFg(index(), lf(), isActive())}>
|
||
{index() === lf() ? "❯" : " "}
|
||
</text>
|
||
<text fg={focusFg(index(), lf(), isActive())}>
|
||
{podcast.title}
|
||
</text>
|
||
<Show when={podcast.isSubscribed}>
|
||
<text
|
||
fg={index() === lf() ? theme.surface : theme.success}
|
||
>
|
||
[+]
|
||
</text>
|
||
</Show>
|
||
</box>
|
||
<Show when={podcast.author}>
|
||
<text
|
||
fg={index() === lf() ? theme.surface : muted()}
|
||
paddingLeft={2}
|
||
>
|
||
by {podcast.author}
|
||
</text>
|
||
</Show>
|
||
</box>
|
||
);
|
||
}}
|
||
</For>
|
||
</Show>
|
||
</Show>
|
||
</>
|
||
);
|
||
|
||
// ── preview pane ───────────────────────────────────────────────────────────
|
||
const previewContent = () =>
|
||
depth() === 0 ? (
|
||
// depth 0 preview: shows for the hovered category
|
||
<Show
|
||
when={focusedCategory()}
|
||
fallback={
|
||
<box padding={1}>
|
||
<text fg={muted()}>No category focused</text>
|
||
</box>
|
||
}
|
||
>
|
||
{(cat) => (
|
||
<box flexDirection="column" gap={0} padding={1}>
|
||
<text fg={theme.textPrimary ?? theme.text}>
|
||
<strong>{cat().name}</strong>
|
||
</text>
|
||
<Show when={(cat() as any).description}>
|
||
<text fg={theme.textSecondary}>{(cat() as any).description}</text>
|
||
</Show>
|
||
<box height={1} />
|
||
<Show
|
||
when={podcasts().length > 0}
|
||
fallback={
|
||
<text fg={muted()}>
|
||
No shows in this category yet. :refresh
|
||
</text>
|
||
}
|
||
>
|
||
<For each={podcasts()}>
|
||
{(pod) => (
|
||
<box flexDirection="column" gap={0}>
|
||
<text fg={theme.text}>{pod.title}</text>
|
||
<Show when={pod.author}>
|
||
<text fg={muted()} paddingLeft={2}>
|
||
by {pod.author}
|
||
</text>
|
||
</Show>
|
||
</box>
|
||
)}
|
||
</For>
|
||
</Show>
|
||
</box>
|
||
)}
|
||
</Show>
|
||
) : (
|
||
// depth ≥1 preview: hovered podcast + subscribe
|
||
<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: back · r: refresh</text>
|
||
</box>
|
||
)}
|
||
</Show>
|
||
);
|
||
|
||
return (
|
||
<PaneRow
|
||
parent={parentContent}
|
||
current={currentContent}
|
||
preview={previewContent}
|
||
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
|
||
currentLabel={currentLabel}
|
||
previewLabel="Detail"
|
||
focused={isActive}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export { DiscoverPage };
|