This commit is contained in:
2026-02-07 18:45:13 -05:00
parent 627fb65547
commit 5bd393c9cd
36 changed files with 187 additions and 879 deletions

View File

@@ -0,0 +1,83 @@
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
type ResultCardProps = {
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const podcast = () => props.result.podcast;
return (
<box
flexDirection="column"
padding={1}
border={props.selected}
borderColor={props.selected ? "cyan" : undefined}
backgroundColor={props.selected ? "#222" : undefined}
onMouseDown={props.onSelect}
>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center">
<text fg={props.selected ? "cyan" : "white"}>
<strong>{podcast().title}</strong>
</text>
<SourceBadge
sourceId={props.result.sourceId}
sourceName={props.result.sourceName}
sourceType={props.result.sourceType}
/>
</box>
<Show when={podcast().isSubscribed}>
<text fg="green">[Subscribed]</text>
</Show>
</box>
<Show when={podcast().author}>
<text fg="gray">by {podcast().author}</text>
</Show>
<Show when={podcast().description}>
{(description) => (
<text fg={props.selected ? "white" : "gray"}>
{description().length > 120
? description().slice(0, 120) + "..."
: description()}
</text>
)}
</Show>
<Show when={(podcast().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(podcast().categories ?? []).slice(0, 3).map((category) => (
<text fg="yellow">[{category}]</text>
))}
</box>
</Show>
<Show when={!podcast().isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg="cyan">[+] Add to Feeds</text>
</box>
</Show>
</box>
);
}

View File

@@ -0,0 +1,73 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
return (
<box flexDirection="column" border padding={1} gap={1} height="100%">
<Show
when={props.result}
fallback={<text fg="gray">Select a result to see details.</text>}
>
{(result) => (
<>
<text fg="white">
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg="gray">by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg="gray">{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg="yellow">[{category}]</text>
))}
</box>
</Show>
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
<text fg="gray">
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
</text>
<Show when={!result().podcast.isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={() => props.onSubscribe?.(result())}
>
<text fg="cyan">[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg="green">Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -0,0 +1,78 @@
/**
* SearchHistory component for displaying and managing search history
*/
import { For, Show } from "solid-js"
type SearchHistoryProps = {
history: string[]
focused: boolean
selectedIndex: number
onSelect?: (query: string) => void
onRemove?: (query: string) => void
onClear?: () => void
onChange?: (index: number) => void
}
export function SearchHistory(props: SearchHistoryProps) {
const handleSearchClick = (index: number, query: string) => {
props.onChange?.(index)
props.onSelect?.(query)
}
const handleRemoveClick = (query: string) => {
props.onRemove?.(query)
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg="gray">Recent Searches</text>
<Show when={props.history.length > 0}>
<box onMouseDown={() => props.onClear?.()} padding={0}>
<text fg="red">[Clear All]</text>
</box>
</Show>
</box>
<Show
when={props.history.length > 0}
fallback={
<box padding={1}>
<text fg="gray">No recent searches</text>
</box>
}
>
<scrollbox height={10}>
<box flexDirection="column">
<For each={props.history}>
{(query, index) => {
const isSelected = () => index() === props.selectedIndex && props.focused
return (
<box
flexDirection="row"
justifyContent="space-between"
padding={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={isSelected() ? "#333" : undefined}
onMouseDown={() => handleSearchClick(index(), query)}
>
<box flexDirection="row" gap={1}>
<text fg="gray">{">"}</text>
<text fg={isSelected() ? "cyan" : "white"}>{query}</text>
</box>
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
<text fg="red">[x]</text>
</box>
</box>
)
}}
</For>
</box>
</scrollbox>
</Show>
</box>
)
}

View File

@@ -0,0 +1,266 @@
/**
* SearchPage component - Main search interface for PodTUI
*/
import { createSignal, createEffect, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults";
import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "@/types/source";
type SearchPageProps = {
focused: boolean;
onSubscribe?: (result: SearchResult) => void;
onInputFocusChange?: (focused: boolean) => void;
onExit?: () => void;
};
type FocusArea = "input" | "results" | "history";
export function SearchPage(props: SearchPageProps) {
const searchStore = useSearchStore();
const [focusArea, setFocusArea] = createSignal<FocusArea>("input");
const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0);
const [historyIndex, setHistoryIndex] = createSignal(0);
// Keep parent informed about input focus state
createEffect(() => {
const isInputFocused = props.focused && focusArea() === "input";
props.onInputFocusChange?.(isInputFocused);
});
const handleSearch = async () => {
const query = inputValue().trim();
if (query) {
await searchStore.search(query);
if (searchStore.results().length > 0) {
setFocusArea("results");
setResultIndex(0);
}
}
};
const handleHistorySelect = async (query: string) => {
setInputValue(query);
await searchStore.search(query);
if (searchStore.results().length > 0) {
setFocusArea("results");
setResultIndex(0);
}
};
const handleResultSelect = (result: SearchResult) => {
props.onSubscribe?.(result);
searchStore.markSubscribed(result.podcast.id);
};
// Keyboard navigation
useKeyboard((key) => {
if (!props.focused) return;
const area = focusArea();
// Enter to search from input
if (key.name === "return" && area === "input") {
handleSearch();
return;
}
// Tab to cycle focus areas
if (key.name === "tab" && !key.shift) {
if (area === "input") {
if (searchStore.results().length > 0) {
setFocusArea("results");
} else if (searchStore.history().length > 0) {
setFocusArea("history");
}
} else if (area === "results") {
if (searchStore.history().length > 0) {
setFocusArea("history");
} else {
setFocusArea("input");
}
} else {
setFocusArea("input");
}
return;
}
if (key.name === "tab" && key.shift) {
if (area === "input") {
if (searchStore.history().length > 0) {
setFocusArea("history");
} else if (searchStore.results().length > 0) {
setFocusArea("results");
}
} else if (area === "history") {
if (searchStore.results().length > 0) {
setFocusArea("results");
} else {
setFocusArea("input");
}
} else {
setFocusArea("input");
}
return;
}
// Up/Down for results and history
if (area === "results") {
const results = searchStore.results();
if (key.name === "down" || key.name === "j") {
setResultIndex((i) => Math.min(i + 1, results.length - 1));
return;
}
if (key.name === "up" || key.name === "k") {
setResultIndex((i) => Math.max(i - 1, 0));
return;
}
if (key.name === "return" || key.name === "enter") {
const result = results[resultIndex()];
if (result) handleResultSelect(result);
return;
}
}
if (area === "history") {
const history = searchStore.history();
if (key.name === "down" || key.name === "j") {
setHistoryIndex((i) => Math.min(i + 1, history.length - 1));
return;
}
if (key.name === "up" || key.name === "k") {
setHistoryIndex((i) => Math.max(i - 1, 0));
return;
}
if (key.name === "return" || key.name === "enter") {
const query = history[historyIndex()];
if (query) handleHistorySelect(query);
return;
}
}
// Escape goes back to input or up one level
if (key.name === "escape") {
if (area === "input") {
props.onExit?.();
} else {
setFocusArea("input");
key.stopPropagation();
}
return;
}
// "/" focuses search input
if (key.name === "/" && area !== "input") {
setFocusArea("input");
return;
}
});
return (
<box flexDirection="column" height="100%" gap={1}>
{/* Search Header */}
<box flexDirection="column" gap={1}>
<text>
<strong>Search Podcasts</strong>
</text>
{/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center">
<text fg="gray">Search:</text>
<input
value={inputValue()}
onInput={(value) => {
setInputValue(value);
}}
placeholder="Enter podcast name, topic, or author..."
focused={props.focused && focusArea() === "input"}
width={50}
/>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={handleSearch}
>
<text fg="cyan">[Enter] Search</text>
</box>
</box>
{/* Status */}
<Show when={searchStore.isSearching()}>
<text fg="yellow">Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text fg="red">{searchStore.error()}</text>
</Show>
</box>
{/* Main Content - Results or History */}
<box flexDirection="row" height="100%" gap={2}>
{/* Results Panel */}
<box flexDirection="column" flexGrow={1} border>
<box padding={1}>
<text fg={focusArea() === "results" ? "cyan" : "gray"}>
Results ({searchStore.results().length})
</text>
</box>
<Show
when={searchStore.results().length > 0}
fallback={
<box padding={2}>
<text fg="gray">
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
</box>
}
>
<SearchResults
results={searchStore.results()}
selectedIndex={resultIndex()}
focused={focusArea() === "results"}
onSelect={handleResultSelect}
onChange={setResultIndex}
isSearching={searchStore.isSearching()}
error={searchStore.error()}
/>
</Show>
</box>
{/* History Sidebar */}
<box width={30} border>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={focusArea() === "history"}
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory}
onChange={setHistoryIndex}
/>
</box>
</box>
</box>
{/* Footer Hints */}
<box flexDirection="row" gap={2}>
<text fg="gray">[Tab] Switch focus</text>
<text fg="gray">[/] Focus search</text>
<text fg="gray">[Enter] Select</text>
<text fg="gray">[Esc] Up</text>
</box>
</box>
);
}

View File

@@ -0,0 +1,80 @@
/**
* SearchResults component for displaying podcast search results
*/
import { For, Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = {
results: SearchResult[];
selectedIndex: number;
focused: boolean;
onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void;
isSearching?: boolean;
error?: string | null;
};
export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => {
props.onChange?.(index);
};
return (
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
<box padding={1}>
<text fg="red">{props.error}</text>
</box>
}
>
<Show
when={props.results.length > 0}
fallback={
<box padding={1}>
<text fg="gray">
No results found. Try a different search term.
</text>
</box>
}
>
<box flexDirection="row" gap={1} height="100%">
<box flexDirection="column" flexGrow={1}>
<scrollbox height="100%">
<box flexDirection="column" gap={1}>
<For each={props.results}>
{(result, index) => (
<ResultCard
result={result}
selected={index() === props.selectedIndex}
onSelect={() => handleSelect(index())}
onSubscribe={() => props.onSelect?.(result)}
/>
)}
</For>
</box>
</scrollbox>
</box>
<box width={36}>
<ResultDetail
result={props.results[props.selectedIndex]}
onSubscribe={(result) => props.onSelect?.(result)}
/>
</box>
</box>
</Show>
</Show>
</Show>
);
}

View File

@@ -0,0 +1,34 @@
import { SourceType } from "@/types/source";
type SourceBadgeProps = {
sourceId: string;
sourceName?: string;
sourceType?: SourceType;
};
const typeLabel = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "API";
if (sourceType === SourceType.RSS) return "RSS";
if (sourceType === SourceType.CUSTOM) return "Custom";
return "Source";
};
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "cyan";
if (sourceType === SourceType.RSS) return "green";
if (sourceType === SourceType.CUSTOM) return "yellow";
return "gray";
};
export function SourceBadge(props: SourceBadgeProps) {
const label = () => props.sourceName || props.sourceId;
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg="gray">{label()}</text>
</box>
);
}