pre-ui-rearch
This commit is contained in:
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* FeedPage — yazi-style 3-pane view of all episodes across subscribed shows.
|
||||
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
||||
*
|
||||
* pane 0 (parent) — subscribed feeds list (the "containers"); an implicit
|
||||
* "All Feeds" entry at index 0 shows every episode.
|
||||
* pane 1 (current) — flat episodes list for the focused feed (reverse
|
||||
* chronological). This is the landing pane.
|
||||
* pane 2 (preview) — detail of the focused episode.
|
||||
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
|
||||
* virtual "All Feeds". Left pane empty at root.
|
||||
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
||||
* chronological). Left pane = the feeds list (prev).
|
||||
* right (preview) — detail of the hovered item in the current column.
|
||||
*
|
||||
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
|
||||
* panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio.
|
||||
* `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the
|
||||
* sidebar at depth 0). j/k move within the current column. The Shell router
|
||||
* drives everything over nav.action; this page only handles list/preview data.
|
||||
*/
|
||||
|
||||
import {
|
||||
createMemo,
|
||||
For,
|
||||
Show,
|
||||
onMount,
|
||||
onCleanup,
|
||||
createEffect,
|
||||
} from "solid-js";
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
@@ -28,8 +22,9 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
@@ -39,9 +34,10 @@ import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
export const FeedPaneCount = 3;
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
|
||||
function FeedPage() {
|
||||
const feedStore = useFeedStore();
|
||||
@@ -52,72 +48,59 @@ function FeedPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent)
|
||||
const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane)
|
||||
const PREV = PaneSlot.PREVIEW; // 2 — episode detail
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
// ── feeds pane data ──────────────────────────────────────────────────────
|
||||
// Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first).
|
||||
// ── feeds list (depth 0) ─────────────────────────────────────────────────
|
||||
const feedList = createMemo<FeedListItem[]>(() => {
|
||||
const all: FeedListItem[] = [{ kind: "all" }];
|
||||
for (const f of feedStore.getFilteredFeeds())
|
||||
all.push({ kind: "feed", feed: f });
|
||||
return all;
|
||||
});
|
||||
const focusedFeedItem = createMemo(() => {
|
||||
const list = feedList();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)];
|
||||
});
|
||||
const focusedFeedIdx = () =>
|
||||
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
|
||||
const focusedFeedItem = (): FeedListItem | undefined =>
|
||||
feedList()[focusedFeedIdx()];
|
||||
|
||||
// ── episodes pane data (filtered by focused feed, or all) ────────────────
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
// ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
|
||||
const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
|
||||
const episodes = createMemo<EpItem[]>(() => {
|
||||
const item = focusedFeedItem();
|
||||
if (!item || item.kind === "all")
|
||||
if (depth() < 1) return [];
|
||||
const id = drilledFeedId();
|
||||
if (id === "all")
|
||||
return feedStore.getAllEpisodesChronological() as EpItem[];
|
||||
return [...item.feed.episodes]
|
||||
const f = feedStore.getFilteredFeeds().find((x) => x.podcast.id === id);
|
||||
if (!f) return [];
|
||||
return [...f.episodes]
|
||||
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
|
||||
.map((episode) => ({ episode, feed: item.feed }));
|
||||
.map((episode) => ({ episode, feed: f }));
|
||||
});
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
||||
|
||||
// Reset episodes focus when the feed filter changes.
|
||||
createEffect(() => {
|
||||
focusedFeedItem();
|
||||
nav.setFocusedIndex(EPS, 0);
|
||||
});
|
||||
|
||||
const focusedItem = createMemo<EpItem | undefined>(() => {
|
||||
const list = episodes();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(EPS), list.length - 1)];
|
||||
});
|
||||
|
||||
// Keep resolvers fresh so visual-mode range selection grows by id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
|
||||
|
||||
const ensureFocus = () => {
|
||||
const eps = episodes();
|
||||
if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length)
|
||||
nav.setFocusedIndex(EPS, eps.length - 1);
|
||||
const fl = feedList();
|
||||
if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length)
|
||||
nav.setFocusedIndex(FEEDS, fl.length - 1);
|
||||
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
|
||||
nav.setDepthFocus(feedList().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) {
|
||||
const it = feedList()[i];
|
||||
return it?.kind === "feed" ? it.feed.podcast.id : "all";
|
||||
}
|
||||
return episodes()[i]?.episode.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
const formatDuration = (s: number) => {
|
||||
@@ -159,24 +142,34 @@ function FeedPage() {
|
||||
audioNav.setSource(AudioSource.FEED);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const item = focusedFeedItem();
|
||||
if (!item) return;
|
||||
const ctx = item.kind === "all" ? "all" : item.feed.podcast.id;
|
||||
nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes
|
||||
if (p === EPS) playEpisode(focusedItem());
|
||||
},
|
||||
"toggle-select": (p) => {
|
||||
if (p === EPS) {
|
||||
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 item = focusedItem();
|
||||
if (item) nav.toggleSelected(item.episode.id);
|
||||
}
|
||||
@@ -188,24 +181,18 @@ function FeedPage() {
|
||||
else feedStore.refreshAllFeeds().catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === FEEDS) return feedList().length;
|
||||
if (pane === EPS) return episodes().length;
|
||||
return 0;
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
action: KeybindActionName;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
@@ -213,243 +200,326 @@ function FeedPage() {
|
||||
});
|
||||
|
||||
// ── 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)
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
: i === listFocus
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
|
||||
const feedLabel = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? "All Feeds"
|
||||
: item.feed.customName || item.feed.podcast.title;
|
||||
const feedCount = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(FEEDS)}
|
||||
border
|
||||
borderColor={border(FEEDS)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
Feeds · {feedList().length - 1}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const label = () =>
|
||||
item.kind === "all"
|
||||
? "All Feeds"
|
||||
: item.feed.customName || item.feed.podcast.title;
|
||||
const count = () =>
|
||||
item.kind === "all"
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), FEEDS)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(FEEDS);
|
||||
nav.setFocusedIndex(FEEDS, index());
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), FEEDS)}>
|
||||
{index() === nav.focusedIndex(FEEDS) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), FEEDS)}>{label()}</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(FEEDS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
({count()})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
{(item, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={muted()}>({feedCount(item)})</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── pane 1 (current, center): episodes ─────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{(() => {
|
||||
const fi = focusedFeedItem();
|
||||
if (fi?.kind === "feed")
|
||||
return fi.feed.customName || fi.feed.podcast.title;
|
||||
return "All Episodes";
|
||||
})()} · {episodes().length}
|
||||
{depth() === 0
|
||||
? `Feeds · ${feedList().length - 1}`
|
||||
: `${(() => {
|
||||
const fi = focusedFeedItem();
|
||||
return fi?.kind === "feed"
|
||||
? fi.feed.customName || fi.feed.podcast.title
|
||||
: "All Episodes";
|
||||
})()} · ${episodes().length}`}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(EPS)}
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(EPS)}
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), EPS)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(EPS);
|
||||
nav.setFocusedIndex(EPS, index());
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{index() === nav.focusedIndex(EPS) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: theme.info
|
||||
}
|
||||
>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
{/* depth 0: feeds */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedFeedIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
({feedCount(item)})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── right: preview of hovered item ───────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PREV)}
|
||||
border
|
||||
borderColor={border(PREV)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(item().episode.pubDate)}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatDuration(item().episode.duration)}
|
||||
</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
{/* depth 0 preview: hovered feed */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={focusedFeedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No feed focused</text>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play space: select h/l: panes</text>
|
||||
</box>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{(item) => {
|
||||
const it = item();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{feedLabel(it)}</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{it.kind === "feed"
|
||||
? `by ${it.feed.podcast.author ?? "unknown"}`
|
||||
: ""}
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>
|
||||
{it.kind === "all"
|
||||
? `${feedCount(it)} episodes across all feeds`
|
||||
: `${feedCount(it)} episodes`}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{it.kind === "feed"
|
||||
? (it.feed.podcast.description?.slice(0, 400) ??
|
||||
"No description.")
|
||||
: "Drill in to see episodes across every feed."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered episode */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => {
|
||||
const it = item();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{it.episode.episodeNumber
|
||||
? `#${it.episode.episodeNumber} `
|
||||
: ""}
|
||||
{it.episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(it.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatDuration(it.episode.duration)}
|
||||
</text>
|
||||
<Show when={downloadLabel(it.episode.id)}>
|
||||
<text fg={downloadColor(it.episode.id)}>
|
||||
{downloadLabel(it.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{it.feed.customName || it.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={it.feed.podcast.author}>
|
||||
<text fg={muted()}>by {it.feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{it.episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · space: select · h: back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
Reference in New Issue
Block a user