broke
This commit is contained in:
37
src/App.tsx
37
src/App.tsx
@@ -11,6 +11,8 @@ import { SyncProfile } from "./components/SyncProfile"
|
||||
import { SearchPage } from "./components/SearchPage"
|
||||
import { DiscoverPage } from "./components/DiscoverPage"
|
||||
import { useAuthStore } from "./stores/auth"
|
||||
import { useFeedStore } from "./stores/feed"
|
||||
import { FeedVisibility } from "./types/feed"
|
||||
import { useAppKeyboard } from "./hooks/useAppKeyboard"
|
||||
import type { TabId } from "./components/Tab"
|
||||
import type { AuthScreen } from "./types/auth"
|
||||
@@ -21,6 +23,7 @@ export function App() {
|
||||
const [showAuthPanel, setShowAuthPanel] = createSignal(false)
|
||||
const [inputFocused, setInputFocused] = createSignal(false)
|
||||
const auth = useAuthStore()
|
||||
const feedStore = useFeedStore()
|
||||
|
||||
// Centralized keyboard handler for all tab navigation and shortcuts
|
||||
useAppKeyboard({
|
||||
@@ -101,27 +104,19 @@ export function App() {
|
||||
<box height={1} />
|
||||
<box border padding={1}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>
|
||||
<span fg="gray">Account:</span>
|
||||
</text>
|
||||
<text fg="gray">Account:</text>
|
||||
{auth.isAuthenticated ? (
|
||||
<text>
|
||||
<span fg="green">Signed in as {auth.user?.email}</span>
|
||||
</text>
|
||||
<text fg="green">Signed in as {auth.user?.email}</text>
|
||||
) : (
|
||||
<text>
|
||||
<span fg="yellow">Not signed in</span>
|
||||
</text>
|
||||
<text fg="yellow">Not signed in</text>
|
||||
)}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={() => setShowAuthPanel(true)}
|
||||
>
|
||||
<text>
|
||||
<span fg="cyan">
|
||||
<text fg="cyan">
|
||||
{auth.isAuthenticated ? "[A] Account" : "[A] Sign In"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -140,8 +135,20 @@ export function App() {
|
||||
focused={!inputFocused()}
|
||||
onInputFocusChange={setInputFocused}
|
||||
onSubscribe={(result) => {
|
||||
// Would add to feeds
|
||||
console.log("Subscribe to:", result.podcast.title)
|
||||
const feeds = feedStore.feeds()
|
||||
const alreadySubscribed = feeds.some(
|
||||
(feed) =>
|
||||
feed.podcast.id === result.podcast.id ||
|
||||
feed.podcast.feedUrl === result.podcast.feedUrl
|
||||
)
|
||||
|
||||
if (!alreadySubscribed) {
|
||||
feedStore.addFeed(
|
||||
{ ...result.podcast, isSubscribed: true },
|
||||
result.sourceId,
|
||||
FeedVisibility.PUBLIC
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -153,7 +160,7 @@ export function App() {
|
||||
<text>
|
||||
<strong>{tab}</strong>
|
||||
<br />
|
||||
<span fg="gray">Player - coming in later phases</span>
|
||||
Player - coming in later phases
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -28,10 +28,8 @@ export function CategoryFilter(props: CategoryFilterProps) {
|
||||
backgroundColor={isSelected() ? "#444" : undefined}
|
||||
onMouseDown={() => props.onSelect?.(category.id)}
|
||||
>
|
||||
<text>
|
||||
<span fg={isSelected() ? "cyan" : "gray"}>
|
||||
<text fg={isSelected() ? "cyan" : "gray"}>
|
||||
{category.icon} {category.name}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -96,47 +96,27 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
padding={2}
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
<text>
|
||||
<strong>Enter Sync Code</strong>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Enter your 8-character sync code to link your account.
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
You can get this code from the web portal.
|
||||
</span>
|
||||
</text>
|
||||
<text fg="gray">Enter your 8-character sync code to link your account.</text>
|
||||
<text fg="gray">You can get this code from the web portal.</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Code display */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg={focusField() === "code" ? "cyan" : undefined}>
|
||||
<text fg={focusField() === "code" ? "cyan" : undefined}>
|
||||
Code ({codeProgress()}):
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<box border padding={1}>
|
||||
<text>
|
||||
<span
|
||||
fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}
|
||||
>
|
||||
<text fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}>
|
||||
{codeDisplay()}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -150,9 +130,7 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
/>
|
||||
|
||||
{codeError() && (
|
||||
<text>
|
||||
<span fg="red">{codeError()}</span>
|
||||
</text>
|
||||
<text fg="red">{codeError()}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
@@ -165,10 +143,8 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "submit" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||
<text fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -177,26 +153,20 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||
<text fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||
[Esc] Back to Login
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Auth error message */}
|
||||
{auth.error && (
|
||||
<text>
|
||||
<span fg="red">{auth.error.message}</span>
|
||||
</text>
|
||||
<text fg="red">{auth.error.message}</text>
|
||||
)}
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
|
||||
</text>
|
||||
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,15 +120,11 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
||||
<strong>Discover Podcasts</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
{discoverStore.filteredPodcasts().length} shows
|
||||
</span>
|
||||
</text>
|
||||
<box onMouseDown={() => discoverStore.refresh()}>
|
||||
<text>
|
||||
<span fg="cyan">[R] Refresh</span>
|
||||
</text>
|
||||
<text fg="cyan">[R] Refresh</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
@@ -136,10 +132,8 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
||||
{/* Category Filter */}
|
||||
<box border padding={1}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>
|
||||
<span fg={focusArea() === "categories" ? "cyan" : "gray"}>
|
||||
<text fg={focusArea() === "categories" ? "cyan" : "gray"}>
|
||||
Categories:
|
||||
</span>
|
||||
</text>
|
||||
<CategoryFilter
|
||||
categories={discoverStore.categories}
|
||||
@@ -152,15 +146,13 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
||||
|
||||
{/* Trending Shows */}
|
||||
<box flexDirection="column" flexGrow={1} border>
|
||||
<box padding={1} borderBottom>
|
||||
<text>
|
||||
<span fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
||||
<box padding={1}>
|
||||
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
||||
Trending in {
|
||||
DISCOVER_CATEGORIES.find(
|
||||
(c) => c.id === discoverStore.selectedCategory()
|
||||
)?.name ?? "All"
|
||||
}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
<TrendingShows
|
||||
@@ -175,18 +167,10 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
||||
|
||||
{/* Footer Hints */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>
|
||||
<span fg="gray">[Tab] Switch focus</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">[j/k] Navigate</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">[Enter] Subscribe</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">[R] Refresh</span>
|
||||
</text>
|
||||
<text fg="gray">[Tab] Switch focus</text>
|
||||
<text fg="gray">[j/k] Navigate</text>
|
||||
<text fg="gray">[Enter] Subscribe</text>
|
||||
<text fg="gray">[R] Refresh</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -73,30 +73,14 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with back button */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onBack}
|
||||
>
|
||||
<text>
|
||||
<span fg="cyan">[Esc] Back</span>
|
||||
</text>
|
||||
<box border padding={0} onMouseDown={props.onBack}>
|
||||
<text fg="cyan">[Esc] Back</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={() => setShowInfo((v) => !v)}
|
||||
>
|
||||
<text>
|
||||
<span fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</span>
|
||||
</text>
|
||||
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)}>
|
||||
<text fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -107,37 +91,31 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</text>
|
||||
{props.feed.podcast.author && (
|
||||
<text>
|
||||
<span fg="gray">by </span>
|
||||
<span fg="cyan">{props.feed.podcast.author}</span>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">by</text>
|
||||
<text fg="cyan">{props.feed.podcast.author}</text>
|
||||
</box>
|
||||
)}
|
||||
<box height={1} />
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
{props.feed.podcast.description?.slice(0, 200)}
|
||||
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
||||
</span>
|
||||
</text>
|
||||
<box height={1} />
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>
|
||||
<span fg="gray">Episodes: </span>
|
||||
<span fg="white">{props.feed.episodes.length}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">Updated: </span>
|
||||
<span fg="white">{formatDate(props.feed.lastUpdated)}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Episodes:</text>
|
||||
<text fg="white">{props.feed.episodes.length}</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Updated:</text>
|
||||
<text fg="white">{formatDate(props.feed.lastUpdated)}</text>
|
||||
</box>
|
||||
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||
</span>
|
||||
</text>
|
||||
{props.feed.isPinned && (
|
||||
<text>
|
||||
<span fg="yellow">[Pinned]</span>
|
||||
</text>
|
||||
<text fg="yellow">[Pinned]</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
@@ -147,8 +125,8 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<strong>Episodes</strong>
|
||||
<span fg="gray"> ({episodes().length} total)</span>
|
||||
</text>
|
||||
<text fg="gray">({episodes().length} total)</text>
|
||||
</box>
|
||||
|
||||
{/* Episode list */}
|
||||
@@ -168,25 +146,17 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||
{index() === selectedIndex() ? ">" : " "}
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={index() === selectedIndex() ? "white" : undefined}>
|
||||
<text fg={index() === selectedIndex() ? "white" : undefined}>
|
||||
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
||||
{episode.title}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text>
|
||||
<span fg="gray">{formatDate(episode.pubDate)}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">{formatDuration(episode.duration)}</span>
|
||||
</text>
|
||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
@@ -194,10 +164,8 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
</scrollbox>
|
||||
|
||||
{/* Help text */}
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import type { FeedFilter } from "../types/feed"
|
||||
|
||||
interface FeedFilterProps {
|
||||
filter: FeedFilter
|
||||
@@ -45,14 +46,19 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
const cycleVisibility = () => {
|
||||
const current = props.filter.visibility
|
||||
let next: FeedVisibility | "all"
|
||||
if (current === "all") next = "public"
|
||||
else if (current === "public") next = "private"
|
||||
if (current === "all") next = FeedVisibility.PUBLIC
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
|
||||
else next = "all"
|
||||
props.onFilterChange({ ...props.filter, visibility: next })
|
||||
}
|
||||
|
||||
const cycleSort = () => {
|
||||
const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"]
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
]
|
||||
const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField)
|
||||
const nextIndex = (currentIndex + 1) % sortOptions.length
|
||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] })
|
||||
@@ -100,13 +106,7 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
padding={1}
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" border padding={1} gap={1}>
|
||||
<text>
|
||||
<strong>Filter Feeds</strong>
|
||||
</text>
|
||||
@@ -118,12 +118,10 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "visibility" ? "cyan" : "gray"}>
|
||||
Show:{" "}
|
||||
</span>
|
||||
<span fg={visibilityColor()}>{visibilityLabel()}</span>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>Show:</text>
|
||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Sort filter */}
|
||||
@@ -132,10 +130,10 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "sort" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "sort" ? "cyan" : "gray"}>Sort: </span>
|
||||
<span fg="white">{sortLabel()}</span>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "sort" ? "cyan" : "gray"}>Sort:</text>
|
||||
<text fg="white">{sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Pinned filter */}
|
||||
@@ -144,22 +142,18 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "pinned" ? "cyan" : "gray"}>
|
||||
Pinned:{" "}
|
||||
</span>
|
||||
<span fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>Pinned:</text>
|
||||
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Search box */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg={focusField() === "search" ? "cyan" : "gray"}>Search:</span>
|
||||
</text>
|
||||
<text fg={focusField() === "search" ? "cyan" : "gray"}>Search:</text>
|
||||
<input
|
||||
value={searchValue()}
|
||||
onInput={handleSearchInput}
|
||||
@@ -169,9 +163,7 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
/>
|
||||
</box>
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter/Space to toggle</span>
|
||||
</text>
|
||||
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,23 +47,15 @@ export function FeedItem(props: FeedItemProps) {
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text>
|
||||
<span fg={props.isSelected ? "cyan" : "gray"}>
|
||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={visibilityColor()}>{visibilityIcon()}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={props.isSelected ? "white" : undefined}>
|
||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||
<text fg={props.isSelected ? "white" : undefined}>
|
||||
{props.feed.customName || props.feed.podcast.title}
|
||||
</span>
|
||||
</text>
|
||||
{props.showEpisodeCount && (
|
||||
<text>
|
||||
<span fg="gray">({episodeCount()})</span>
|
||||
</text>
|
||||
<text fg="gray">({episodeCount()})</text>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
@@ -81,50 +73,34 @@ export function FeedItem(props: FeedItemProps) {
|
||||
>
|
||||
{/* Title row */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg={props.isSelected ? "cyan" : "gray"}>
|
||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={visibilityColor()}>{visibilityIcon()}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="yellow">{pinnedIndicator()}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={props.isSelected ? "white" : undefined}>
|
||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||
<text fg="yellow">{pinnedIndicator()}</text>
|
||||
<text fg={props.isSelected ? "white" : undefined}>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Details row */}
|
||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
||||
{props.showEpisodeCount && (
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
{episodeCount()} episodes ({unplayedCount()} new)
|
||||
</span>
|
||||
</text>
|
||||
)}
|
||||
{props.showLastUpdated && (
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Updated: {formatDate(props.feed.lastUpdated)}
|
||||
</span>
|
||||
</text>
|
||||
<text fg="gray">Updated: {formatDate(props.feed.lastUpdated)}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{/* Description (truncated) */}
|
||||
{props.feed.podcast.description && (
|
||||
<box paddingLeft={4} paddingTop={0}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
{props.feed.podcast.description.slice(0, 60)}
|
||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { FeedItem } from "./FeedItem"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import type { Feed } from "../types/feed"
|
||||
|
||||
interface FeedListProps {
|
||||
focused?: boolean
|
||||
@@ -67,14 +68,19 @@ export function FeedList(props: FeedListProps) {
|
||||
const cycleVisibilityFilter = () => {
|
||||
const current = feedStore.filter().visibility
|
||||
let next: FeedVisibility | "all"
|
||||
if (current === "all") next = "public"
|
||||
else if (current === "public") next = "private"
|
||||
if (current === "all") next = FeedVisibility.PUBLIC
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
|
||||
else next = "all"
|
||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next })
|
||||
}
|
||||
|
||||
const cycleSortField = () => {
|
||||
const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"]
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
]
|
||||
const current = feedStore.filter().sortBy as FeedSortField
|
||||
const idx = sortOptions.indexOf(current)
|
||||
const next = sortOptions[(idx + 1) % sortOptions.length]
|
||||
@@ -112,35 +118,27 @@ export function FeedList(props: FeedListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with filter controls */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||
<text>
|
||||
<strong>My Feeds</strong>
|
||||
<span fg="gray"> ({filteredFeeds().length} feeds)</span>
|
||||
</text>
|
||||
<text fg="gray">({filteredFeeds().length} feeds)</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={cycleVisibilityFilter}
|
||||
>
|
||||
<text>
|
||||
<span fg="cyan">[f] {visibilityLabel()}</span>
|
||||
</text>
|
||||
<text fg="cyan">[f] {visibilityLabel()}</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={cycleSortField}
|
||||
>
|
||||
<text>
|
||||
<span fg="cyan">[s] {sortLabel()}</span>
|
||||
</text>
|
||||
<text fg="cyan">[s] {sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
@@ -150,25 +148,16 @@ export function FeedList(props: FeedListProps) {
|
||||
when={filteredFeeds().length > 0}
|
||||
fallback={
|
||||
<box border padding={2}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox
|
||||
height={15}
|
||||
focused={props.focused}
|
||||
selectedIndex={selectedIndex()}
|
||||
>
|
||||
<scrollbox height={15} focused={props.focused}>
|
||||
<For each={filteredFeeds()}>
|
||||
{(feed, index) => (
|
||||
<box
|
||||
onMouseDown={() => handleFeedClick(feed, index())}
|
||||
onDoubleClick={() => handleFeedDoubleClick(feed)}
|
||||
>
|
||||
<box onMouseDown={() => handleFeedClick(feed, index())}>
|
||||
<FeedItem
|
||||
feed={feed}
|
||||
isSelected={index() === selectedIndex()}
|
||||
@@ -184,10 +173,8 @@ export function FeedList(props: FeedListProps) {
|
||||
|
||||
{/* Navigation help */}
|
||||
<box paddingTop={0}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
j/k navigate | Enter open | p pin | f filter | s sort | Click to select
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -81,13 +81,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
padding={2}
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
<text>
|
||||
<strong>Sign In</strong>
|
||||
</text>
|
||||
@@ -96,11 +90,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
|
||||
{/* Email field */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg={focusField() === "email" ? "cyan" : undefined}>
|
||||
Email:
|
||||
</span>
|
||||
</text>
|
||||
<text fg={focusField() === "email" ? "cyan" : undefined}>Email:</text>
|
||||
<input
|
||||
value={email()}
|
||||
onInput={setEmail}
|
||||
@@ -109,18 +99,14 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
width={30}
|
||||
/>
|
||||
{emailError() && (
|
||||
<text>
|
||||
<span fg="red">{emailError()}</span>
|
||||
</text>
|
||||
<text fg="red">{emailError()}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{/* Password field */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg={focusField() === "password" ? "cyan" : undefined}>
|
||||
<text fg={focusField() === "password" ? "cyan" : undefined}>
|
||||
Password:
|
||||
</span>
|
||||
</text>
|
||||
<input
|
||||
value={password()}
|
||||
@@ -130,9 +116,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
width={30}
|
||||
/>
|
||||
{passwordError() && (
|
||||
<text>
|
||||
<span fg="red">{passwordError()}</span>
|
||||
</text>
|
||||
<text fg="red">{passwordError()}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
@@ -145,27 +129,21 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "submit" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||
<text fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Auth error message */}
|
||||
{auth.error && (
|
||||
<text>
|
||||
<span fg="red">{auth.error.message}</span>
|
||||
</text>
|
||||
<text fg="red">{auth.error.message}</text>
|
||||
)}
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Alternative auth options */}
|
||||
<text>
|
||||
<span fg="gray">Or authenticate with:</span>
|
||||
</text>
|
||||
<text fg="gray">Or authenticate with:</text>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
@@ -173,10 +151,8 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "code" ? "yellow" : "gray"}>
|
||||
<text fg={focusField() === "code" ? "yellow" : "gray"}>
|
||||
[C] Sync Code
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -185,19 +161,15 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "oauth" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "oauth" ? "yellow" : "gray"}>
|
||||
<text fg={focusField() === "oauth" ? "yellow" : "gray"}>
|
||||
[O] OAuth Info
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select</span>
|
||||
</text>
|
||||
<text fg="gray">Tab to navigate, Enter to select</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,13 +38,7 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
padding={2}
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
<text>
|
||||
<strong>OAuth Authentication</strong>
|
||||
</text>
|
||||
@@ -52,18 +46,16 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
<box height={1} />
|
||||
|
||||
{/* OAuth providers list */}
|
||||
<text>
|
||||
<span fg="cyan">Available OAuth Providers:</span>
|
||||
</text>
|
||||
<text fg="cyan">Available OAuth Providers:</text>
|
||||
|
||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||
{OAUTH_PROVIDERS.map((provider) => (
|
||||
<text>
|
||||
<span fg={provider.enabled ? "green" : "gray"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={provider.enabled ? "green" : "gray"}>
|
||||
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
||||
</span>
|
||||
<span fg="gray"> - {provider.description}</span>
|
||||
</text>
|
||||
<text fg="gray">- {provider.description}</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
|
||||
@@ -71,39 +63,33 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
|
||||
{/* Limitation message */}
|
||||
<box border padding={1} borderColor="yellow">
|
||||
<text>
|
||||
<span fg="yellow">Terminal Limitations</span>
|
||||
</text>
|
||||
<text fg="yellow">Terminal Limitations</text>
|
||||
</box>
|
||||
|
||||
<box paddingLeft={1}>
|
||||
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
||||
<text>
|
||||
<span fg="gray">{line}</span>
|
||||
</text>
|
||||
<text fg="gray">{line}</text>
|
||||
))}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Alternative options */}
|
||||
<text>
|
||||
<span fg="cyan">Recommended Alternatives:</span>
|
||||
</text>
|
||||
<text fg="cyan">Recommended Alternatives:</text>
|
||||
|
||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||
<text>
|
||||
<span fg="green">[1]</span>
|
||||
<span fg="white"> Use a sync code from the web portal</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="green">[2]</span>
|
||||
<span fg="white"> Use email/password authentication</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="green">[3]</span>
|
||||
<span fg="white"> Use file-based sync (no account needed)</span>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="green">[1]</text>
|
||||
<text fg="white">Use a sync code from the web portal</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="green">[2]</text>
|
||||
<text fg="white">Use email/password authentication</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="green">[3]</text>
|
||||
<text fg="white">Use file-based sync (no account needed)</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
@@ -115,10 +101,8 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "code" ? "cyan" : undefined}>
|
||||
<text fg={focusField() === "code" ? "cyan" : undefined}>
|
||||
[C] Enter Sync Code
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -127,19 +111,15 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||
<text fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||
[Esc] Back to Login
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
|
||||
</text>
|
||||
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ type PodcastCardProps = {
|
||||
}
|
||||
|
||||
export function PodcastCard(props: PodcastCardProps) {
|
||||
const handleSubscribeClick = (e: MouseEvent) => {
|
||||
e.stopPropagation?.()
|
||||
const handleSubscribeClick = () => {
|
||||
props.onSubscribe?.()
|
||||
}
|
||||
|
||||
@@ -28,55 +27,43 @@ export function PodcastCard(props: PodcastCardProps) {
|
||||
>
|
||||
{/* Title Row */}
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text>
|
||||
<span fg={props.selected ? "cyan" : "white"}>
|
||||
<text fg={props.selected ? "cyan" : "white"}>
|
||||
<strong>{props.podcast.title}</strong>
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<Show when={props.podcast.isSubscribed}>
|
||||
<text>
|
||||
<span fg="green">[+]</span>
|
||||
</text>
|
||||
<text fg="green">[+]</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Author */}
|
||||
<Show when={props.podcast.author && !props.compact}>
|
||||
<text>
|
||||
<span fg="gray">by {props.podcast.author}</span>
|
||||
</text>
|
||||
<text fg="gray">by {props.podcast.author}</text>
|
||||
</Show>
|
||||
|
||||
{/* Description */}
|
||||
<Show when={props.podcast.description && !props.compact}>
|
||||
<text>
|
||||
<span fg={props.selected ? "white" : "gray"}>
|
||||
<text fg={props.selected ? "white" : "gray"}>
|
||||
{props.podcast.description!.length > 80
|
||||
? props.podcast.description!.slice(0, 80) + "..."
|
||||
: props.podcast.description}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
{/* Categories and Subscribe Button */}
|
||||
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={props.podcast.categories && props.podcast.categories.length > 0}>
|
||||
{props.podcast.categories!.slice(0, 2).map((cat) => (
|
||||
<text>
|
||||
<span fg="yellow">[{cat}]</span>
|
||||
</text>
|
||||
<Show when={(props.podcast.categories ?? []).length > 0}>
|
||||
{(props.podcast.categories ?? []).slice(0, 2).map((cat) => (
|
||||
<text fg="yellow">[{cat}]</text>
|
||||
))}
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show when={props.selected}>
|
||||
<box onMouseDown={handleSubscribeClick}>
|
||||
<text>
|
||||
<span fg={props.podcast.isSubscribed ? "red" : "green"}>
|
||||
<text fg={props.podcast.isSubscribed ? "red" : "green"}>
|
||||
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
79
src/components/ResultCard.tsx
Normal file
79
src/components/ResultCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
75
src/components/ResultDetail.tsx
Normal file
75
src/components/ResultDetail.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -20,22 +20,17 @@ export function SearchHistory(props: SearchHistoryProps) {
|
||||
props.onSelect?.(query)
|
||||
}
|
||||
|
||||
const handleRemoveClick = (e: MouseEvent, query: string) => {
|
||||
e.stopPropagation?.()
|
||||
const handleRemoveClick = (query: string) => {
|
||||
props.onRemove?.(query)
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<span fg="gray">Recent Searches</span>
|
||||
</text>
|
||||
<text fg="gray">Recent Searches</text>
|
||||
<Show when={props.history.length > 0}>
|
||||
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
||||
<text>
|
||||
<span fg="red">[Clear All]</span>
|
||||
</text>
|
||||
<text fg="red">[Clear All]</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -44,13 +39,11 @@ export function SearchHistory(props: SearchHistoryProps) {
|
||||
when={props.history.length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text>
|
||||
<span fg="gray">No recent searches</span>
|
||||
</text>
|
||||
<text fg="gray">No recent searches</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height={10} showScrollIndicator>
|
||||
<scrollbox height={10}>
|
||||
<box flexDirection="column">
|
||||
<For each={props.history}>
|
||||
{(query, index) => {
|
||||
@@ -67,20 +60,11 @@ export function SearchHistory(props: SearchHistoryProps) {
|
||||
onMouseDown={() => handleSearchClick(index(), query)}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">{">"}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={isSelected() ? "cyan" : "white"}>{query}</span>
|
||||
</text>
|
||||
<text fg="gray">{">"}</text>
|
||||
<text fg={isSelected() ? "cyan" : "white"}>{query}</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseDown={(e: MouseEvent) => handleRemoveClick(e, query)}
|
||||
padding={0}
|
||||
>
|
||||
<text>
|
||||
<span fg="red">[x]</span>
|
||||
</text>
|
||||
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
||||
<text fg="red">[x]</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ export function SearchPage(props: SearchPageProps) {
|
||||
|
||||
const handleResultSelect = (result: SearchResult) => {
|
||||
props.onSubscribe?.(result)
|
||||
searchStore.markSubscribed(result.podcast.id)
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
@@ -168,17 +169,18 @@ export function SearchPage(props: SearchPageProps) {
|
||||
|
||||
{/* Search Input */}
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text>
|
||||
<span fg="gray">Search:</span>
|
||||
</text>
|
||||
<text fg="gray">Search:</text>
|
||||
<input
|
||||
value={inputValue()}
|
||||
onInput={setInputValue}
|
||||
onInput={(value) => {
|
||||
setInputValue(value)
|
||||
if (props.focused && focusArea() === "input") {
|
||||
props.onInputFocusChange?.(true)
|
||||
}
|
||||
}}
|
||||
placeholder="Enter podcast name, topic, or author..."
|
||||
focused={props.focused && focusArea() === "input"}
|
||||
width={50}
|
||||
onFocus={() => props.onInputFocusChange?.(true)}
|
||||
onBlur={() => props.onInputFocusChange?.(false)}
|
||||
/>
|
||||
<box
|
||||
border
|
||||
@@ -187,22 +189,16 @@ export function SearchPage(props: SearchPageProps) {
|
||||
paddingRight={1}
|
||||
onMouseDown={handleSearch}
|
||||
>
|
||||
<text>
|
||||
<span fg="cyan">[Enter] Search</span>
|
||||
</text>
|
||||
<text fg="cyan">[Enter] Search</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Status */}
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<text>
|
||||
<span fg="yellow">Searching...</span>
|
||||
</text>
|
||||
<text fg="yellow">Searching...</text>
|
||||
</Show>
|
||||
<Show when={searchStore.error()}>
|
||||
<text>
|
||||
<span fg="red">{searchStore.error()}</span>
|
||||
</text>
|
||||
<text fg="red">{searchStore.error()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -210,23 +206,19 @@ export function SearchPage(props: SearchPageProps) {
|
||||
<box flexDirection="row" height="100%" gap={2}>
|
||||
{/* Results Panel */}
|
||||
<box flexDirection="column" flexGrow={1} border>
|
||||
<box padding={1} borderBottom>
|
||||
<text>
|
||||
<span fg={focusArea() === "results" ? "cyan" : "gray"}>
|
||||
<box padding={1}>
|
||||
<text fg={focusArea() === "results" ? "cyan" : "gray"}>
|
||||
Results ({searchStore.results().length})
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={searchStore.results().length > 0}
|
||||
fallback={
|
||||
<box padding={2}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
<text fg="gray">
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
@@ -237,6 +229,8 @@ export function SearchPage(props: SearchPageProps) {
|
||||
focused={focusArea() === "results"}
|
||||
onSelect={handleResultSelect}
|
||||
onChange={setResultIndex}
|
||||
isSearching={searchStore.isSearching()}
|
||||
error={searchStore.error()}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -244,11 +238,9 @@ export function SearchPage(props: SearchPageProps) {
|
||||
{/* History Sidebar */}
|
||||
<box width={30} border>
|
||||
<box padding={1} flexDirection="column">
|
||||
<box borderBottom paddingBottom={1}>
|
||||
<text>
|
||||
<span fg={focusArea() === "history" ? "cyan" : "gray"}>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
|
||||
History
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
<SearchHistory
|
||||
@@ -266,18 +258,10 @@ export function SearchPage(props: SearchPageProps) {
|
||||
|
||||
{/* Footer Hints */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>
|
||||
<span fg="gray">[Tab] Switch focus</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">[/] Focus search</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">[Enter] Select</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">[Esc] Back to search</span>
|
||||
</text>
|
||||
<text fg="gray">[Tab] Switch focus</text>
|
||||
<text fg="gray">[/] Focus search</text>
|
||||
<text fg="gray">[Enter] Select</text>
|
||||
<text fg="gray">[Esc] Back to search</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import { For, Show } from "solid-js"
|
||||
import type { SearchResult } from "../types/source"
|
||||
import { ResultCard } from "./ResultCard"
|
||||
import { ResultDetail } from "./ResultDetail"
|
||||
|
||||
type SearchResultsProps = {
|
||||
results: SearchResult[]
|
||||
@@ -11,88 +13,63 @@ type SearchResultsProps = {
|
||||
focused: boolean
|
||||
onSelect?: (result: SearchResult) => void
|
||||
onChange?: (index: number) => void
|
||||
isSearching?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function SearchResults(props: SearchResultsProps) {
|
||||
const handleMouseDown = (index: number, result: SearchResult) => {
|
||||
const handleSelect = (index: number) => {
|
||||
props.onChange?.(index)
|
||||
props.onSelect?.(result)
|
||||
}
|
||||
|
||||
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>
|
||||
<span fg="gray">No results found. Try a different search term.</span>
|
||||
</text>
|
||||
<text fg="gray">No results found. Try a different search term.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height="100%" showScrollIndicator>
|
||||
<box flexDirection="column">
|
||||
<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) => {
|
||||
const isSelected = () => index() === props.selectedIndex
|
||||
const podcast = result.podcast
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
backgroundColor={isSelected() ? "#333" : undefined}
|
||||
onMouseDown={() => handleMouseDown(index(), result)}
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>
|
||||
<span fg={isSelected() ? "cyan" : "white"}>
|
||||
<strong>{podcast.title}</strong>
|
||||
</span>
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text>
|
||||
<span fg="green">[Subscribed]</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text>
|
||||
<span fg="gray">({result.sourceId})</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={podcast.author}>
|
||||
<text>
|
||||
<span fg="gray">by {podcast.author}</span>
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={podcast.description}>
|
||||
<text>
|
||||
<span fg={isSelected() ? "white" : "gray"}>
|
||||
{podcast.description!.length > 100
|
||||
? podcast.description!.slice(0, 100) + "..."
|
||||
: podcast.description}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={podcast.categories && podcast.categories.length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={podcast.categories!.slice(0, 3)}>
|
||||
{(category) => (
|
||||
<text>
|
||||
<span fg="yellow">[{category}]</span>
|
||||
</text>
|
||||
{(result, index) => (
|
||||
<ResultCard
|
||||
result={result}
|
||||
selected={index() === props.selectedIndex}
|
||||
onSelect={() => handleSelect(index())}
|
||||
onSubscribe={() => props.onSelect?.(result)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</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/components/SourceBadge.tsx
Normal file
34
src/components/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>
|
||||
)
|
||||
}
|
||||
@@ -106,35 +106,21 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
padding={1}
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" border padding={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<strong>Podcast Sources</strong>
|
||||
</text>
|
||||
<box border padding={0} onMouseDown={props.onClose}>
|
||||
<text>
|
||||
<span fg="cyan">[Esc] Close</span>
|
||||
</text>
|
||||
<text fg="cyan">[Esc] Close</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Manage where to search for podcasts
|
||||
</span>
|
||||
</text>
|
||||
<text fg="gray">Manage where to search for podcasts</text>
|
||||
|
||||
{/* Source list */}
|
||||
<box border padding={1} flexDirection="column">
|
||||
<text>
|
||||
<span fg={focusArea() === "list" ? "cyan" : "gray"}>Sources:</span>
|
||||
</text>
|
||||
<text fg={focusArea() === "list" ? "cyan" : "gray"}>Sources:</text>
|
||||
<scrollbox height={6}>
|
||||
<For each={sources()}>
|
||||
{(source, index) => (
|
||||
@@ -153,29 +139,20 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
feedStore.toggleSource(source.id)
|
||||
}}
|
||||
>
|
||||
<text>
|
||||
<span
|
||||
fg={
|
||||
<text fg={
|
||||
focusArea() === "list" && index() === selectedIndex()
|
||||
? "cyan"
|
||||
: "gray"
|
||||
}
|
||||
>
|
||||
}>
|
||||
{focusArea() === "list" && index() === selectedIndex()
|
||||
? ">"
|
||||
: " "}
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={source.enabled ? "green" : "red"}>
|
||||
<text fg={source.enabled ? "green" : "red"}>
|
||||
{source.enabled ? "[x]" : "[ ]"}
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="yellow">{getSourceIcon(source)}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span
|
||||
<text fg="yellow">{getSourceIcon(source)}</text>
|
||||
<text
|
||||
fg={
|
||||
focusArea() === "list" && index() === selectedIndex()
|
||||
? "white"
|
||||
@@ -183,31 +160,22 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
}
|
||||
>
|
||||
{source.name}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Space/Enter to toggle, d to delete, a to add
|
||||
</span>
|
||||
</text>
|
||||
<text fg="gray">Space/Enter to toggle, d to delete, a to add</text>
|
||||
</box>
|
||||
|
||||
{/* Add new source form */}
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text>
|
||||
<span fg={focusArea() === "add" || focusArea() === "url" ? "cyan" : "gray"}>
|
||||
<text fg={focusArea() === "add" || focusArea() === "url" ? "cyan" : "gray"}>
|
||||
Add New Source:
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">Name:</span>
|
||||
</text>
|
||||
<text fg="gray">Name:</text>
|
||||
<input
|
||||
value={newSourceName()}
|
||||
onInput={setNewSourceName}
|
||||
@@ -218,9 +186,7 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">URL:</span>
|
||||
</text>
|
||||
<text fg="gray">URL:</text>
|
||||
<input
|
||||
value={newSourceUrl()}
|
||||
onInput={(v) => {
|
||||
@@ -239,22 +205,16 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
width={15}
|
||||
onMouseDown={handleAddSource}
|
||||
>
|
||||
<text>
|
||||
<span fg="green">[+] Add Source</span>
|
||||
</text>
|
||||
<text fg="green">[+] Add Source</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Error message */}
|
||||
{error() && (
|
||||
<text>
|
||||
<span fg="red">{error()}</span>
|
||||
</text>
|
||||
<text fg="red">{error()}</text>
|
||||
)}
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to switch sections, Esc to close</span>
|
||||
</text>
|
||||
<text fg="gray">Tab to switch sections, Esc to close</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,13 +59,7 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
padding={2}
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
<text>
|
||||
<strong>User Profile</strong>
|
||||
</text>
|
||||
@@ -76,24 +70,14 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
<box flexDirection="row" gap={2}>
|
||||
{/* ASCII avatar */}
|
||||
<box border padding={1} width={8} height={4} justifyContent="center" alignItems="center">
|
||||
<text>
|
||||
<span fg="cyan">{userInitials()}</span>
|
||||
</text>
|
||||
<text fg="cyan">{userInitials()}</text>
|
||||
</box>
|
||||
|
||||
{/* User details */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg="white">{user()?.name || "Guest User"}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">{user()?.email || "No email"}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Joined: {formatDate(user()?.createdAt)}
|
||||
</span>
|
||||
</text>
|
||||
<text fg="white">{user()?.name || "Guest User"}</text>
|
||||
<text fg="gray">{user()?.email || "No email"}</text>
|
||||
<text fg="gray">Joined: {formatDate(user()?.createdAt)}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -101,37 +85,23 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
|
||||
{/* Sync status section */}
|
||||
<box border padding={1} flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg="cyan">Sync Status</span>
|
||||
</text>
|
||||
<text fg="cyan">Sync Status</text>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">Status:</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={user()?.syncEnabled ? "green" : "yellow"}>
|
||||
<text fg="gray">Status:</text>
|
||||
<text fg={user()?.syncEnabled ? "green" : "yellow"}>
|
||||
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">Last Sync:</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="white">{formatDate(lastSyncTime())}</span>
|
||||
</text>
|
||||
<text fg="gray">Last Sync:</text>
|
||||
<text fg="white">{formatDate(lastSyncTime())}</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">Method:</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="white">File-based (JSON/XML)</span>
|
||||
</text>
|
||||
<text fg="gray">Method:</text>
|
||||
<text fg="white">File-based (JSON/XML)</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -144,10 +114,8 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "sync" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "sync" ? "cyan" : undefined}>
|
||||
<text fg={focusField() === "sync" ? "cyan" : undefined}>
|
||||
[S] Manage Sync
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -156,10 +124,8 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "export" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "export" ? "cyan" : undefined}>
|
||||
<text fg={focusField() === "export" ? "cyan" : undefined}>
|
||||
[E] Export Data
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -168,19 +134,15 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "logout" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "logout" ? "red" : "gray"}>
|
||||
<text fg={focusField() === "logout" ? "red" : "gray"}>
|
||||
[L] Logout
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select</span>
|
||||
</text>
|
||||
<text fg="gray">Tab to navigate, Enter to select</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,22 +20,18 @@ export function TrendingShows(props: TrendingShowsProps) {
|
||||
<box flexDirection="column" height="100%">
|
||||
<Show when={props.isLoading}>
|
||||
<box padding={2}>
|
||||
<text>
|
||||
<span fg="yellow">Loading trending shows...</span>
|
||||
</text>
|
||||
<text fg="yellow">Loading trending shows...</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.isLoading && props.podcasts.length === 0}>
|
||||
<box padding={2}>
|
||||
<text>
|
||||
<span fg="gray">No podcasts found in this category.</span>
|
||||
</text>
|
||||
<text fg="gray">No podcasts found in this category.</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.isLoading && props.podcasts.length > 0}>
|
||||
<scrollbox height="100%" showScrollIndicator>
|
||||
<scrollbox height="100%">
|
||||
<box flexDirection="column">
|
||||
<For each={props.podcasts}>
|
||||
{(podcast, index) => (
|
||||
|
||||
@@ -35,7 +35,7 @@ const TRENDING_PODCASTS: Podcast[] = [
|
||||
feedUrl: "https://example.com/aitoday.rss",
|
||||
author: "Tech Futures",
|
||||
categories: ["Technology", "Science"],
|
||||
imageUrl: undefined,
|
||||
coverUrl: undefined,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { Episode, EpisodeStatus } from "../types/episode"
|
||||
import type { PodcastSource, SourceType } from "../types/source"
|
||||
@@ -287,7 +288,7 @@ export function createFeedStore() {
|
||||
}
|
||||
|
||||
/** Add a new feed */
|
||||
const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = "public") => {
|
||||
const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
||||
const newFeed: Feed = {
|
||||
id: crypto.randomUUID(),
|
||||
podcast,
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { PodcastSource, SearchResult } from "../types/source"
|
||||
import { searchPodcasts } from "../utils/search"
|
||||
import { useFeedStore } from "./feed"
|
||||
import type { SearchResult } from "../types/source"
|
||||
|
||||
const STORAGE_KEY = "podtui_search_history"
|
||||
const MAX_HISTORY = 20
|
||||
@@ -17,89 +18,7 @@ export interface SearchState {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** Mock search results for demonstration */
|
||||
const MOCK_PODCASTS: Podcast[] = [
|
||||
{
|
||||
id: "search-1",
|
||||
title: "Tech Talk Daily",
|
||||
description: "Daily technology news and analysis from Silicon Valley experts.",
|
||||
feedUrl: "https://example.com/techtalk.rss",
|
||||
author: "Tech Media Group",
|
||||
categories: ["Technology", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-2",
|
||||
title: "The Science Hour",
|
||||
description: "Weekly deep dives into the latest scientific discoveries and research.",
|
||||
feedUrl: "https://example.com/sciencehour.rss",
|
||||
author: "Science Network",
|
||||
categories: ["Science", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-3",
|
||||
title: "History Lessons",
|
||||
description: "Fascinating stories from history that shaped our world.",
|
||||
feedUrl: "https://example.com/historylessons.rss",
|
||||
author: "History Channel",
|
||||
categories: ["History", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-4",
|
||||
title: "Business Insights",
|
||||
description: "Expert analysis on business trends, markets, and entrepreneurship.",
|
||||
feedUrl: "https://example.com/businessinsights.rss",
|
||||
author: "Business Weekly",
|
||||
categories: ["Business", "Finance"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-5",
|
||||
title: "True Crime Stories",
|
||||
description: "In-depth investigations into real criminal cases and mysteries.",
|
||||
feedUrl: "https://example.com/truecrime.rss",
|
||||
author: "Crime Network",
|
||||
categories: ["True Crime", "Documentary"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-6",
|
||||
title: "Comedy Hour",
|
||||
description: "Stand-up comedy, sketches, and hilarious conversations.",
|
||||
feedUrl: "https://example.com/comedyhour.rss",
|
||||
author: "Laugh Factory",
|
||||
categories: ["Comedy", "Entertainment"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-7",
|
||||
title: "Mindful Living",
|
||||
description: "Meditation, wellness, and mental health tips for a better life.",
|
||||
feedUrl: "https://example.com/mindful.rss",
|
||||
author: "Wellness Media",
|
||||
categories: ["Health", "Self-Help"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-8",
|
||||
title: "Sports Central",
|
||||
description: "Coverage of all major sports, analysis, and athlete interviews.",
|
||||
feedUrl: "https://example.com/sportscentral.rss",
|
||||
author: "Sports Network",
|
||||
categories: ["Sports", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
]
|
||||
const CACHE_TTL = 1000 * 60 * 5
|
||||
|
||||
/** Load search history from localStorage */
|
||||
function loadHistory(): string[] {
|
||||
@@ -124,6 +43,7 @@ function saveHistory(history: string[]): void {
|
||||
|
||||
/** Create search store */
|
||||
export function createSearchStore() {
|
||||
const feedStore = useFeedStore()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [isSearching, setIsSearching] = createSignal(false)
|
||||
const [results, setResults] = createSignal<SearchResult[]>([])
|
||||
@@ -131,7 +51,24 @@ export function createSearchStore() {
|
||||
const [history, setHistory] = createSignal<string[]>(loadHistory())
|
||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
|
||||
|
||||
/** Perform search (mock implementation) */
|
||||
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
||||
const feeds = feedStore.feeds()
|
||||
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl))
|
||||
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id))
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
podcast: {
|
||||
...item.podcast,
|
||||
isSubscribed:
|
||||
item.podcast.isSubscribed ||
|
||||
subscribedUrls.has(item.podcast.feedUrl) ||
|
||||
subscribedIds.has(item.podcast.id),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Perform search (multi-source implementation) */
|
||||
const search = async (searchQuery: string): Promise<void> => {
|
||||
const q = searchQuery.trim()
|
||||
if (!q) {
|
||||
@@ -146,28 +83,18 @@ export function createSearchStore() {
|
||||
// Add to history
|
||||
addToHistory(q)
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise((r) => setTimeout(r, 300 + Math.random() * 500))
|
||||
|
||||
try {
|
||||
// Mock search - filter by query
|
||||
const queryLower = q.toLowerCase()
|
||||
const matchingPodcasts = MOCK_PODCASTS.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(queryLower) ||
|
||||
p.description.toLowerCase().includes(queryLower) ||
|
||||
p.categories?.some((c) => c.toLowerCase().includes(queryLower)) ||
|
||||
p.author?.toLowerCase().includes(queryLower)
|
||||
)
|
||||
const sources = feedStore.sources()
|
||||
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id)
|
||||
const sourceIds = selectedSources().length > 0
|
||||
? selectedSources()
|
||||
: enabledSourceIds
|
||||
|
||||
// Convert to search results
|
||||
const searchResults: SearchResult[] = matchingPodcasts.map((podcast, i) => ({
|
||||
sourceId: i % 2 === 0 ? "itunes" : "rss",
|
||||
podcast,
|
||||
score: 1 - i * 0.1, // Mock relevance score
|
||||
}))
|
||||
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
||||
cacheTtl: CACHE_TTL,
|
||||
})
|
||||
|
||||
setResults(searchResults)
|
||||
setResults(applySubscribedStatus(searchResults))
|
||||
} catch (e) {
|
||||
setError("Search failed. Please try again.")
|
||||
setResults([])
|
||||
@@ -209,6 +136,26 @@ export function createSearchStore() {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
/** Mark a podcast as subscribed in results */
|
||||
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||
setResults((prev) =>
|
||||
prev.map((result) => {
|
||||
const matchesId = result.podcast.id === podcastId
|
||||
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false
|
||||
if (matchesId || matchesUrl) {
|
||||
return {
|
||||
...result,
|
||||
podcast: {
|
||||
...result.podcast,
|
||||
isSubscribed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
query,
|
||||
@@ -225,6 +172,7 @@ export function createSearchStore() {
|
||||
clearHistory,
|
||||
removeFromHistory,
|
||||
setSelectedSources,
|
||||
markSubscribed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,14 @@ export interface PodcastSource {
|
||||
iconUrl?: string
|
||||
/** Source description */
|
||||
description?: string
|
||||
/** Default country for source searches */
|
||||
country?: string
|
||||
/** Default language for search results */
|
||||
language?: string
|
||||
/** Default results limit */
|
||||
searchLimit?: number
|
||||
/** Include explicit results */
|
||||
allowExplicit?: boolean
|
||||
/** Rate limit (requests per minute) */
|
||||
rateLimit?: number
|
||||
/** Last successful fetch */
|
||||
@@ -76,6 +84,10 @@ export enum SearchSortField {
|
||||
export interface SearchResult {
|
||||
/** Source that returned this result */
|
||||
sourceId: string
|
||||
/** Source display name */
|
||||
sourceName?: string
|
||||
/** Source type */
|
||||
sourceType?: SourceType
|
||||
/** Podcast data */
|
||||
podcast: import("./podcast").Podcast
|
||||
/** Relevance score (0-1) */
|
||||
@@ -91,6 +103,10 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
|
||||
baseUrl: "https://itunes.apple.com/search",
|
||||
enabled: true,
|
||||
description: "Search the Apple Podcasts directory",
|
||||
country: "US",
|
||||
language: "en_us",
|
||||
searchLimit: 25,
|
||||
allowExplicit: true,
|
||||
},
|
||||
{
|
||||
id: "rss",
|
||||
|
||||
91
src/utils/search.ts
Normal file
91
src/utils/search.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { searchSourceByType } from "./source-searcher"
|
||||
import type { PodcastSource, SearchResult } from "../types/source"
|
||||
import type { Episode } from "../types/episode"
|
||||
|
||||
type SearchCacheEntry = {
|
||||
timestamp: number
|
||||
results: SearchResult[]
|
||||
}
|
||||
|
||||
type SearchOptions = {
|
||||
cacheTtl?: number
|
||||
}
|
||||
|
||||
const searchCache = new Map<string, SearchCacheEntry>()
|
||||
|
||||
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
||||
const keySources = [...sourceIds].sort().join(",")
|
||||
return `${query.toLowerCase()}::${keySources}`
|
||||
}
|
||||
|
||||
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||
Date.now() - entry.timestamp < ttl
|
||||
|
||||
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||
const map = new Map<string, SearchResult>()
|
||||
for (const result of results) {
|
||||
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title
|
||||
const existing = map.get(key)
|
||||
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||
map.set(key, result)
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
export const searchPodcasts = async (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult[]> => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const activeSources = sources.filter(
|
||||
(source) => sourceIds.includes(source.id) && source.enabled
|
||||
)
|
||||
|
||||
if (activeSources.length === 0) return []
|
||||
|
||||
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5
|
||||
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id))
|
||||
const cached = searchCache.get(cacheKey)
|
||||
if (cached && isCacheValid(cached, cacheTtl)) {
|
||||
return cached.results
|
||||
}
|
||||
|
||||
const results: SearchResult[] = []
|
||||
const errors: Error[] = []
|
||||
|
||||
await Promise.all(
|
||||
activeSources.map(async (source) => {
|
||||
try {
|
||||
const sourceResults = await searchSourceByType(trimmed, source)
|
||||
results.push(...sourceResults)
|
||||
} catch (error) {
|
||||
errors.push(error as Error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const deduped = dedupeResults(results)
|
||||
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||
|
||||
if (sorted.length === 0 && errors.length > 0) {
|
||||
throw new Error("Search failed for all sources")
|
||||
}
|
||||
|
||||
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted })
|
||||
return sorted
|
||||
}
|
||||
|
||||
export const searchEpisodes = async (
|
||||
query: string,
|
||||
_feedId: string
|
||||
): Promise<Episode[]> => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
return []
|
||||
}
|
||||
196
src/utils/source-searcher.ts
Normal file
196
src/utils/source-searcher.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { SourceType } from "../types/source"
|
||||
import type { PodcastSource, SearchResult } from "../types/source"
|
||||
|
||||
type SearcherResult = SearchResult[]
|
||||
|
||||
const delay = async (min = 200, max = 500) =>
|
||||
new Promise((resolve) => setTimeout(resolve, min + Math.random() * max))
|
||||
|
||||
const hashString = (input: string): number => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i)
|
||||
hash |= 0
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
const slugify = (input: string): string =>
|
||||
input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
|
||||
const sourceLabel = (source: PodcastSource): string =>
|
||||
source.name || source.id
|
||||
|
||||
const buildPodcast = (
|
||||
idBase: string,
|
||||
title: string,
|
||||
description: string,
|
||||
author: string,
|
||||
categories: string[],
|
||||
source: PodcastSource
|
||||
): Podcast => ({
|
||||
id: idBase,
|
||||
title,
|
||||
description,
|
||||
feedUrl: `https://example.com/${slugify(title)}/feed.xml`,
|
||||
author,
|
||||
categories,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
})
|
||||
|
||||
const makeResults = (query: string, source: PodcastSource, seedOffset = 0): SearcherResult => {
|
||||
const seed = hashString(`${source.id}:${query}`) + seedOffset
|
||||
const baseTitles = [
|
||||
"Daily Briefing",
|
||||
"Studio Sessions",
|
||||
"Signal & Noise",
|
||||
"The Long Play",
|
||||
"Off the Record",
|
||||
]
|
||||
const descriptors = [
|
||||
"Deep dives into",
|
||||
"A fast-paced look at",
|
||||
"Smart conversations about",
|
||||
"A weekly roundup of",
|
||||
"Curated stories on",
|
||||
]
|
||||
const categories = ["Technology", "Business", "Science", "Culture", "News"]
|
||||
|
||||
return baseTitles.map((base, index) => {
|
||||
const title = `${query} ${base}`
|
||||
const desc = `${descriptors[index % descriptors.length]} ${query.toLowerCase()} from ${sourceLabel(source)}.`
|
||||
const author = `${sourceLabel(source)} Network`
|
||||
const cat = [categories[(seed + index) % categories.length]]
|
||||
const podcast = buildPodcast(
|
||||
`search-${source.id}-${seed + index}`,
|
||||
title,
|
||||
desc,
|
||||
author,
|
||||
cat,
|
||||
source
|
||||
)
|
||||
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
sourceType: source.type,
|
||||
podcast,
|
||||
score: 1 - index * 0.08,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const searchRSSSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
await delay(200, 450)
|
||||
return makeResults(query, source, 1)
|
||||
}
|
||||
|
||||
type ItunesResult = {
|
||||
collectionId?: number
|
||||
collectionName?: string
|
||||
artistName?: string
|
||||
feedUrl?: string
|
||||
artworkUrl100?: string
|
||||
artworkUrl600?: string
|
||||
primaryGenreName?: string
|
||||
releaseDate?: string
|
||||
}
|
||||
|
||||
type ItunesResponse = {
|
||||
resultCount: number
|
||||
results: ItunesResult[]
|
||||
}
|
||||
|
||||
const buildItunesUrl = (query: string, source: PodcastSource) => {
|
||||
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
|
||||
const url = new URL(baseUrl)
|
||||
const params = url.searchParams
|
||||
|
||||
params.set("term", query.trim())
|
||||
params.set("media", "podcast")
|
||||
params.set("entity", "podcast")
|
||||
params.set("limit", String(source.searchLimit ?? 25))
|
||||
params.set("country", source.country ?? "US")
|
||||
params.set("lang", source.language ?? "en_us")
|
||||
params.set("explicit", source.allowExplicit === false ? "No" : "Yes")
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
|
||||
if (!result.collectionName || !result.feedUrl) return null
|
||||
|
||||
const id = result.collectionId
|
||||
? `itunes-${result.collectionId}`
|
||||
: `itunes-${slugify(result.collectionName)}`
|
||||
|
||||
const descriptionParts = [result.collectionName]
|
||||
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
|
||||
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
|
||||
|
||||
return {
|
||||
id,
|
||||
title: result.collectionName,
|
||||
description: descriptionParts.join(" • "),
|
||||
feedUrl: result.feedUrl,
|
||||
author: result.artistName,
|
||||
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
|
||||
coverUrl: result.artworkUrl600 || result.artworkUrl100,
|
||||
lastUpdated: result.releaseDate ? new Date(result.releaseDate) : new Date(),
|
||||
isSubscribed: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const searchAPISource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
const url = buildItunesUrl(query, source)
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`iTunes search failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ItunesResponse
|
||||
const results = data.results
|
||||
.map((item) => mapItunesResult(item, source))
|
||||
.filter((item): item is Podcast => Boolean(item))
|
||||
|
||||
return results.map((podcast, index) => ({
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
sourceType: source.type,
|
||||
podcast,
|
||||
score: 1 - index * 0.02,
|
||||
}))
|
||||
}
|
||||
|
||||
export const searchCustomSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
await delay(300, 650)
|
||||
return makeResults(query, source, 13)
|
||||
}
|
||||
|
||||
export const searchSourceByType = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
if (source.type === SourceType.RSS) {
|
||||
return searchRSSSource(query, source)
|
||||
}
|
||||
if (source.type === SourceType.CUSTOM) {
|
||||
return searchCustomSource(query, source)
|
||||
}
|
||||
return searchAPISource(query, source)
|
||||
}
|
||||
Reference in New Issue
Block a user