435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
/**
|
||
* SearchPage — yazi depth-stack view of podcast search.
|
||
*
|
||
* depth 0 (current) — query input row + recent-searches list (navigable
|
||
* with j/k when the input is defocused). Parent pane
|
||
* shows the tab list (muted); preview shows a hint.
|
||
* depth 1 (current) — search results list. Parent pane shows the submitted
|
||
* query (muted, read-only); preview shows the detail of
|
||
* the focused result.
|
||
*
|
||
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
||
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||
* input (or on a focused recent at depth 0) submits the query and pushes to
|
||
* depth 1 (results). `h` pops: results→query, query→tab root.
|
||
*/
|
||
|
||
import {
|
||
createSignal,
|
||
createMemo,
|
||
createEffect,
|
||
For,
|
||
Show,
|
||
onMount,
|
||
onCleanup,
|
||
} from "solid-js";
|
||
import { useSearchStore } from "@/stores/search";
|
||
import { useFeedStore } from "@/stores/feed";
|
||
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 type { SearchResult } from "@/types/source";
|
||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||
import { TabListPane } from "@/components/TabPanel";
|
||
|
||
export const SearchPaneCount = 1;
|
||
|
||
function SearchPage() {
|
||
const searchStore = useSearchStore();
|
||
const feedStore = useFeedStore();
|
||
const [inputValue, setInputValue] = createSignal("");
|
||
const { theme } = useTheme();
|
||
const muted = () => theme.muted || theme.text;
|
||
const nav = useNavigation();
|
||
|
||
const stack = nav.depthStack;
|
||
const depth = nav.currentDepth;
|
||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||
|
||
// depth 1's ctx carries the submitted query string.
|
||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||
|
||
// ── input focusing ────────────────────────────────────────────────────────
|
||
// `inputFocused` is true while the query input is being typed in. The Shell
|
||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
||
//
|
||
// Typing is the default only on the query depth (0); the results depth
|
||
// (1) is always list-navigation. Drive `inputFocused` straight off
|
||
// `depth()` rather than seeding it `true` on mount and patching on change:
|
||
// the depth stack persists across tab switches, so re-mounting this page
|
||
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
|
||
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
|
||
// j/k (yielding to a non-existent input) and only the scrollbox's native
|
||
// scroll responds.
|
||
//
|
||
// The effect only re-runs on a depth transition, so Escape (defocus) and
|
||
// `s` (refocus) at the same depth are not clobbered.
|
||
onMount(() => nav.setInputFocused(depth() === 0));
|
||
onCleanup(() => nav.setInputFocused(false));
|
||
createEffect(() => {
|
||
nav.setInputFocused(depth() === 0);
|
||
});
|
||
|
||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||
const results = () => searchStore.results();
|
||
const focusedResultIdx = () =>
|
||
results().length === 0 ? 0 : Math.min(focus(1), results().length - 1);
|
||
const focusedResult = createMemo(() => {
|
||
const list = results();
|
||
if (list.length === 0) return undefined;
|
||
return list[focusedResultIdx()];
|
||
});
|
||
|
||
// ── recents (depth 0) ────────────────────────────────────────────────────
|
||
const recents = () => searchStore.history();
|
||
const curLen = () => (depth() === 0 ? recents().length : results().length);
|
||
|
||
const ensureFocus = () => {
|
||
if (depth() === 1 && results().length > 0 && focus(1) >= results().length)
|
||
nav.setDepthFocus(results().length - 1, 1);
|
||
};
|
||
onMount(ensureFocus);
|
||
|
||
// Register a visual-mode resolver for the results list (depth 1).
|
||
onMount(() => {
|
||
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
||
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
|
||
});
|
||
|
||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||
|
||
const runSearch = (query: string) => {
|
||
const q = query.trim();
|
||
if (!q) return;
|
||
searchStore.search(q).catch(() => {});
|
||
nav.pushDepth({
|
||
kind: "search:results",
|
||
ctx: q,
|
||
focus: 0,
|
||
} as DepthFrame);
|
||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||
};
|
||
|
||
const handleSubmit = () => runSearch(inputValue());
|
||
|
||
const selectRecent = (query: string) => {
|
||
setInputValue(query);
|
||
runSearch(query);
|
||
};
|
||
|
||
const handleSubscribe = (result: SearchResult) => {
|
||
// Actually add the feed to the feed store, then mark the result subscribed
|
||
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
|
||
searchStore.markSubscribed(result.podcast.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 r = focusedResult();
|
||
if (r) nav.toggleSelected(r.podcast.id);
|
||
}
|
||
},
|
||
search: () => {
|
||
// `s` refocuses the query input (typing mode) when on the query depth.
|
||
if (depth() === 0) nav.setInputFocused(true);
|
||
},
|
||
refresh: () => {
|
||
const q = submittedQuery() || inputValue().trim();
|
||
if (q) searchStore.search(q).catch(() => {});
|
||
},
|
||
};
|
||
|
||
function step(delta: number) {
|
||
nav.move(delta, curLen());
|
||
}
|
||
function open() {
|
||
if (depth() === 0) {
|
||
// Enter/l on a focused recent search → submit it and drill to results.
|
||
const list = recents();
|
||
const idx = Math.min(focus(0), list.length - 1);
|
||
const q = list[idx];
|
||
if (q) selectRecent(q);
|
||
return;
|
||
}
|
||
if (depth() === 1) {
|
||
const r = focusedResult();
|
||
if (r) handleSubscribe(r);
|
||
}
|
||
}
|
||
|
||
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 inputActive = () => nav.inputFocused() && depth() === 0;
|
||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||
i === listFocus && active
|
||
? theme.primary
|
||
: i === listFocus
|
||
? theme.border
|
||
: undefined;
|
||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||
i === listFocus && active ? theme.surface : theme.text;
|
||
|
||
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
||
const parentContent = () => (
|
||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||
<box flexDirection="column" gap={1} padding={1}>
|
||
<text fg={theme.textSecondary}>Query</text>
|
||
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
||
<box height={1} />
|
||
<text fg={muted()}>h: back to query</text>
|
||
</box>
|
||
</Show>
|
||
);
|
||
|
||
// ── current pane ────────────────────────────────────────────────────────────
|
||
const currentContent = () => (
|
||
<>
|
||
<Show when={depth() === 0}>
|
||
{/* query input row + recent searches */}
|
||
<box flexDirection="column" gap={1} padding={1}>
|
||
<box flexDirection="row" gap={1} alignItems="center">
|
||
<text fg={muted()}>Query:</text>
|
||
<input
|
||
value={inputValue()}
|
||
onInput={setInputValue}
|
||
onSubmit={() => handleSubmit()}
|
||
placeholder="Enter podcast name..."
|
||
focused={inputActive()}
|
||
width={28}
|
||
/>
|
||
</box>
|
||
<Show when={searchStore.isSearching()}>
|
||
<text fg={theme.warning}>Searching...</text>
|
||
</Show>
|
||
<Show when={searchStore.error()}>
|
||
<text fg={theme.error}>{searchStore.error()}</text>
|
||
</Show>
|
||
<box height={1} />
|
||
<text fg={theme.textSecondary}>Recent</text>
|
||
<Show
|
||
when={recents().length > 0}
|
||
fallback={
|
||
<text fg={muted()}>
|
||
{inputActive()
|
||
? "Enter to search"
|
||
: "s to type · Enter to search"}
|
||
</text>
|
||
}
|
||
>
|
||
<For each={recents()}>
|
||
{(query, index) => {
|
||
const lf = () => focus(0);
|
||
return (
|
||
<box
|
||
flexDirection="row"
|
||
gap={1}
|
||
paddingLeft={1}
|
||
paddingRight={1}
|
||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||
onMouseDown={() => {
|
||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||
nav.setDepthFocus(index(), 0);
|
||
}}
|
||
>
|
||
<text fg={focusFg(index(), lf(), isActive())}>
|
||
{index() === lf() ? "❯" : " "}
|
||
</text>
|
||
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
|
||
</box>
|
||
);
|
||
}}
|
||
</For>
|
||
</Show>
|
||
<box height={1} />
|
||
<text fg={muted()}>
|
||
{inputActive()
|
||
? "Enter to search · Esc to defocus"
|
||
: "j/k recents · s to type · h back"}
|
||
</text>
|
||
</box>
|
||
</Show>
|
||
<Show when={depth() >= 1}>
|
||
{/* results list */}
|
||
<Show
|
||
when={results().length > 0}
|
||
fallback={
|
||
<box padding={1}>
|
||
<text fg={muted()}>
|
||
{searchStore.query()
|
||
? "No results found"
|
||
: "Enter a search term to find podcasts"}
|
||
</text>
|
||
</box>
|
||
}
|
||
>
|
||
<For each={results()}>
|
||
{(result, index) => {
|
||
const fi = () => focusedResultIdx();
|
||
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())}>
|
||
{result.podcast.title}
|
||
</text>
|
||
<Show when={result.podcast.isSubscribed}>
|
||
<text
|
||
fg={index() === fi() ? theme.surface : theme.success}
|
||
>
|
||
[+]
|
||
</text>
|
||
</Show>
|
||
</box>
|
||
<Show when={result.podcast.author}>
|
||
<text
|
||
fg={index() === fi() ? theme.surface : muted()}
|
||
paddingLeft={2}
|
||
>
|
||
by {result.podcast.author}
|
||
</text>
|
||
</Show>
|
||
</box>
|
||
);
|
||
}}
|
||
</For>
|
||
</Show>
|
||
</Show>
|
||
</>
|
||
);
|
||
|
||
// ── preview pane ────────────────────────────────────────────────────────────
|
||
const previewContent = () =>
|
||
depth() === 0 ? (
|
||
<box flexDirection="column" gap={1} padding={1}>
|
||
<text fg={theme.textPrimary ?? theme.text}>
|
||
<strong>Search</strong>
|
||
</text>
|
||
<text fg={muted()}>Type a query, press Enter to search.</text>
|
||
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||
<box height={1} />
|
||
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||
<For each={recents().slice(0, 6)}>
|
||
{(q) => <text fg={muted()}>‣ {q}</text>}
|
||
</For>
|
||
</box>
|
||
) : (
|
||
<Show
|
||
when={focusedResult()}
|
||
fallback={
|
||
<box padding={1}>
|
||
<text fg={muted()}>No result focused</text>
|
||
</box>
|
||
}
|
||
>
|
||
{(result) => (
|
||
<box flexDirection="column" gap={1} padding={1}>
|
||
<text fg={theme.text}>
|
||
<strong>{result().podcast.title}</strong>
|
||
</text>
|
||
<Show when={result().podcast.author}>
|
||
<text fg={muted()}>by {result().podcast.author}</text>
|
||
</Show>
|
||
<Show when={result().podcast.description}>
|
||
<text fg={theme.textSecondary}>
|
||
{result().podcast.description!.slice(0, 400) ??
|
||
"No description available."}
|
||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||
</text>
|
||
</Show>
|
||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||
<box flexDirection="row" gap={1}>
|
||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||
</For>
|
||
</box>
|
||
</Show>
|
||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||
<text fg={muted()}>
|
||
Updated: {formatDate(result().podcast.lastUpdated)}
|
||
</text>
|
||
<Show when={result().sourceName}>
|
||
<text fg={muted()}>Source: {result().sourceName}</text>
|
||
</Show>
|
||
<box height={1} />
|
||
<Show when={!result().podcast.isSubscribed}>
|
||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||
</Show>
|
||
<Show when={result().podcast.isSubscribed}>
|
||
<text fg={theme.success}>Already subscribed</text>
|
||
</Show>
|
||
<box height={1} />
|
||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||
</box>
|
||
)}
|
||
</Show>
|
||
);
|
||
|
||
const currentLabel = () =>
|
||
depth() === 0
|
||
? `Search · ${recents().length} recent`
|
||
: `Results · ${results().length}`;
|
||
|
||
return (
|
||
<YaziPaneRow
|
||
parent={parentContent}
|
||
current={currentContent}
|
||
preview={previewContent}
|
||
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
|
||
currentLabel={currentLabel}
|
||
previewLabel="Detail"
|
||
focused={isActive}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export { SearchPage };
|