cooking
This commit is contained in:
83
src/pages/Search/ResultCard.tsx
Normal file
83
src/pages/Search/ResultCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/pages/Search/ResultDetail.tsx
Normal file
73
src/pages/Search/ResultDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/pages/Search/SearchHistory.tsx
Normal file
78
src/pages/Search/SearchHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
266
src/pages/Search/SearchPage.tsx
Normal file
266
src/pages/Search/SearchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/pages/Search/SearchResults.tsx
Normal file
80
src/pages/Search/SearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/pages/Search/SourceBadge.tsx
Normal file
34
src/pages/Search/SourceBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user