From f7df578461585e80e9eb5191e1733243a7036c41 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 4 Feb 2026 09:39:58 -0500 Subject: [PATCH] broke --- src/App.tsx | 39 +++--- src/components/CategoryFilter.tsx | 6 +- src/components/CodeValidation.tsx | 58 ++------ src/components/DiscoverPage.tsx | 48 +++---- src/components/FeedDetail.tsx | 98 +++++--------- src/components/FeedFilter.tsx | 60 ++++----- src/components/FeedItem.tsx | 60 +++------ src/components/FeedList.tsx | 53 +++----- src/components/LoginScreen.tsx | 58 +++----- src/components/OAuthPlaceholder.tsx | 74 ++++------- src/components/PodcastCard.tsx | 41 ++---- src/components/ResultCard.tsx | 79 +++++++++++ src/components/ResultDetail.tsx | 75 +++++++++++ src/components/SearchHistory.tsx | 34 ++--- src/components/SearchPage.tsx | 90 ++++++------- src/components/SearchResults.tsx | 125 ++++++++---------- src/components/SourceBadge.tsx | 34 +++++ src/components/SourceManager.tsx | 102 +++++---------- src/components/SyncProfile.tsx | 78 +++-------- src/components/TrendingShows.tsx | 10 +- src/stores/discover.ts | 2 +- src/stores/feed.ts | 5 +- src/stores/search.ts | 158 ++++++++-------------- src/types/source.ts | 16 +++ src/utils/search.ts | 91 +++++++++++++ src/utils/source-searcher.ts | 196 ++++++++++++++++++++++++++++ 26 files changed, 907 insertions(+), 783 deletions(-) create mode 100644 src/components/ResultCard.tsx create mode 100644 src/components/ResultDetail.tsx create mode 100644 src/components/SourceBadge.tsx create mode 100644 src/utils/search.ts create mode 100644 src/utils/source-searcher.ts diff --git a/src/App.tsx b/src/App.tsx index 0536eea..8a02cf3 100644 --- a/src/App.tsx +++ b/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() { - - Account: - + Account: {auth.isAuthenticated ? ( - - Signed in as {auth.user?.email} - + Signed in as {auth.user?.email} ) : ( - - Not signed in - + Not signed in )} setShowAuthPanel(true)} > - - - {auth.isAuthenticated ? "[A] Account" : "[A] Sign In"} - + + {auth.isAuthenticated ? "[A] Account" : "[A] Sign In"} @@ -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() { {tab}
- Player - coming in later phases + Player - coming in later phases
) diff --git a/src/components/CategoryFilter.tsx b/src/components/CategoryFilter.tsx index 18c03f2..875237d 100644 --- a/src/components/CategoryFilter.tsx +++ b/src/components/CategoryFilter.tsx @@ -28,10 +28,8 @@ export function CategoryFilter(props: CategoryFilterProps) { backgroundColor={isSelected() ? "#444" : undefined} onMouseDown={() => props.onSelect?.(category.id)} > - - - {category.icon} {category.name} - + + {category.icon} {category.name}
) diff --git a/src/components/CodeValidation.tsx b/src/components/CodeValidation.tsx index b1e8f83..59d5886 100644 --- a/src/components/CodeValidation.tsx +++ b/src/components/CodeValidation.tsx @@ -96,47 +96,27 @@ export function CodeValidation(props: CodeValidationProps) { } return ( - + Enter Sync Code - - - Enter your 8-character sync code to link your account. - - - - - You can get this code from the web portal. - - + Enter your 8-character sync code to link your account. + You can get this code from the web portal. {/* Code display */} - - - Code ({codeProgress()}): - + + Code ({codeProgress()}): - - - {codeDisplay()} - + + {codeDisplay()} @@ -150,9 +130,7 @@ export function CodeValidation(props: CodeValidationProps) { /> {codeError() && ( - - {codeError()} - + {codeError()} )} @@ -165,10 +143,8 @@ export function CodeValidation(props: CodeValidationProps) { padding={1} backgroundColor={focusField() === "submit" ? "#333" : undefined} > - - - {auth.isLoading ? "Validating..." : "[Enter] Validate Code"} - + + {auth.isLoading ? "Validating..." : "[Enter] Validate Code"} @@ -177,26 +153,20 @@ export function CodeValidation(props: CodeValidationProps) { padding={1} backgroundColor={focusField() === "back" ? "#333" : undefined} > - - - [Esc] Back to Login - + + [Esc] Back to Login {/* Auth error message */} {auth.error && ( - - {auth.error.message} - + {auth.error.message} )} - - Tab to navigate, Enter to select, Esc to go back - + Tab to navigate, Enter to select, Esc to go back ) } diff --git a/src/components/DiscoverPage.tsx b/src/components/DiscoverPage.tsx index 94ba2d8..1a45c28 100644 --- a/src/components/DiscoverPage.tsx +++ b/src/components/DiscoverPage.tsx @@ -116,19 +116,15 @@ export function DiscoverPage(props: DiscoverPageProps) { {/* Header */} - - Discover Podcasts - - - - {discoverStore.filteredPodcasts().length} shows - + Discover Podcasts + + + + {discoverStore.filteredPodcasts().length} shows discoverStore.refresh()}> - - [R] Refresh - + [R] Refresh @@ -136,10 +132,8 @@ export function DiscoverPage(props: DiscoverPageProps) { {/* Category Filter */} - - - Categories: - + + Categories: - - - + + Trending in { DISCOVER_CATEGORIES.find( (c) => c.id === discoverStore.selectedCategory() )?.name ?? "All" } - - - + + - - [Tab] Switch focus - - - [j/k] Navigate - - - [Enter] Subscribe - - - [R] Refresh - + [Tab] Switch focus + [j/k] Navigate + [Enter] Subscribe + [R] Refresh ) diff --git a/src/components/FeedDetail.tsx b/src/components/FeedDetail.tsx index 104ea7a..9d5cb8f 100644 --- a/src/components/FeedDetail.tsx +++ b/src/components/FeedDetail.tsx @@ -73,30 +73,14 @@ export function FeedDetail(props: FeedDetailProps) { } return ( - + {/* Header with back button */} - - - [Esc] Back - + + [Esc] Back - setShowInfo((v) => !v)} - > - - [i] {showInfo() ? "Hide" : "Show"} Info - + setShowInfo((v) => !v)}> + [i] {showInfo() ? "Hide" : "Show"} Info @@ -107,37 +91,31 @@ export function FeedDetail(props: FeedDetailProps) { {props.feed.customName || props.feed.podcast.title} {props.feed.podcast.author && ( - - by - {props.feed.podcast.author} - + + by + {props.feed.podcast.author} + )} - - - {props.feed.podcast.description?.slice(0, 200)} - {(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""} - + + {props.feed.podcast.description?.slice(0, 200)} + {(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""} - - Episodes: - {props.feed.episodes.length} - - - Updated: - {formatDate(props.feed.lastUpdated)} - - - - {props.feed.visibility === "public" ? "[Public]" : "[Private]"} - + + Episodes: + {props.feed.episodes.length} + + + Updated: + {formatDate(props.feed.lastUpdated)} + + + {props.feed.visibility === "public" ? "[Public]" : "[Private]"} {props.feed.isPinned && ( - - [Pinned] - + [Pinned] )} @@ -147,8 +125,8 @@ export function FeedDetail(props: FeedDetailProps) { Episodes - ({episodes().length} total) + ({episodes().length} total) {/* Episode list */} @@ -168,25 +146,17 @@ export function FeedDetail(props: FeedDetailProps) { }} > - - - {index() === selectedIndex() ? ">" : " "} - + + {index() === selectedIndex() ? ">" : " "} - - - {episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""} - {episode.title} - + + {episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""} + {episode.title} - - {formatDate(episode.pubDate)} - - - {formatDuration(episode.duration)} - + {formatDate(episode.pubDate)} + {formatDuration(episode.duration)} )} @@ -194,10 +164,8 @@ export function FeedDetail(props: FeedDetailProps) { {/* Help text */} - - - j/k to navigate, Enter to play, i to toggle info, Esc to go back - + + j/k to navigate, Enter to play, i to toggle info, Esc to go back ) diff --git a/src/components/FeedFilter.tsx b/src/components/FeedFilter.tsx index 944cb7c..f3a9392 100644 --- a/src/components/FeedFilter.tsx +++ b/src/components/FeedFilter.tsx @@ -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 ( - + Filter Feeds @@ -118,12 +118,10 @@ export function FeedFilterComponent(props: FeedFilterProps) { padding={0} backgroundColor={focusField() === "visibility" ? "#333" : undefined} > - - - Show:{" "} - - {visibilityLabel()} - + + Show: + {visibilityLabel()} + {/* Sort filter */} @@ -132,10 +130,10 @@ export function FeedFilterComponent(props: FeedFilterProps) { padding={0} backgroundColor={focusField() === "sort" ? "#333" : undefined} > - - Sort: - {sortLabel()} - + + Sort: + {sortLabel()} + {/* Pinned filter */} @@ -144,22 +142,18 @@ export function FeedFilterComponent(props: FeedFilterProps) { padding={0} backgroundColor={focusField() === "pinned" ? "#333" : undefined} > - - - Pinned:{" "} - - + + Pinned: + {props.filter.pinnedOnly ? "Yes" : "No"} - - + + {/* Search box */} - - Search: - + Search: - - Tab to navigate, Enter/Space to toggle - + Tab to navigate, Enter/Space to toggle ) } diff --git a/src/components/FeedItem.tsx b/src/components/FeedItem.tsx index ba56913..ba6c5ec 100644 --- a/src/components/FeedItem.tsx +++ b/src/components/FeedItem.tsx @@ -47,23 +47,15 @@ export function FeedItem(props: FeedItemProps) { paddingLeft={1} paddingRight={1} > - - - {props.isSelected ? ">" : " "} - + + {props.isSelected ? ">" : " "} - - {visibilityIcon()} - - - - {props.feed.customName || props.feed.podcast.title} - + {visibilityIcon()} + + {props.feed.customName || props.feed.podcast.title} {props.showEpisodeCount && ( - - ({episodeCount()}) - + ({episodeCount()}) )} ) @@ -81,50 +73,34 @@ export function FeedItem(props: FeedItemProps) { > {/* Title row */} - - - {props.isSelected ? ">" : " "} - + + {props.isSelected ? ">" : " "} - - {visibilityIcon()} - - - {pinnedIndicator()} - - - - {props.feed.customName || props.feed.podcast.title} - + {visibilityIcon()} + {pinnedIndicator()} + + {props.feed.customName || props.feed.podcast.title} {/* Details row */} {props.showEpisodeCount && ( - - - {episodeCount()} episodes ({unplayedCount()} new) - + + {episodeCount()} episodes ({unplayedCount()} new) )} {props.showLastUpdated && ( - - - Updated: {formatDate(props.feed.lastUpdated)} - - + Updated: {formatDate(props.feed.lastUpdated)} )} {/* Description (truncated) */} {props.feed.podcast.description && ( - - - {props.feed.podcast.description.slice(0, 60)} - {props.feed.podcast.description.length > 60 ? "..." : ""} - + + {props.feed.podcast.description.slice(0, 60)} + {props.feed.podcast.description.length > 60 ? "..." : ""} )} diff --git a/src/components/FeedList.tsx b/src/components/FeedList.tsx index 8479123..f4f812c 100644 --- a/src/components/FeedList.tsx +++ b/src/components/FeedList.tsx @@ -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 ( - + {/* Header with filter controls */} My Feeds - ({filteredFeeds().length} feeds) + ({filteredFeeds().length} feeds) - - [f] {visibilityLabel()} - + [f] {visibilityLabel()} - - [s] {sortLabel()} - + [s] {sortLabel()} @@ -150,25 +148,16 @@ export function FeedList(props: FeedListProps) { when={filteredFeeds().length > 0} fallback={ - - - No feeds found. Add podcasts from the Discover or Search tabs. - + + No feeds found. Add podcasts from the Discover or Search tabs. } > - + {(feed, index) => ( - handleFeedClick(feed, index())} - onDoubleClick={() => handleFeedDoubleClick(feed)} - > + handleFeedClick(feed, index())}> - - - j/k navigate | Enter open | p pin | f filter | s sort | Click to select - + + j/k navigate | Enter open | p pin | f filter | s sort | Click to select diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index 70bd795..b256fb9 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -81,13 +81,7 @@ export function LoginScreen(props: LoginScreenProps) { } return ( - + Sign In @@ -96,11 +90,7 @@ export function LoginScreen(props: LoginScreenProps) { {/* Email field */} - - - Email: - - + Email: {emailError() && ( - - {emailError()} - + {emailError()} )} {/* Password field */} - - - Password: - + + Password: {passwordError() && ( - - {passwordError()} - + {passwordError()} )} @@ -145,27 +129,21 @@ export function LoginScreen(props: LoginScreenProps) { padding={1} backgroundColor={focusField() === "submit" ? "#333" : undefined} > - - - {auth.isLoading ? "Signing in..." : "[Enter] Sign In"} - + + {auth.isLoading ? "Signing in..." : "[Enter] Sign In"} {/* Auth error message */} {auth.error && ( - - {auth.error.message} - + {auth.error.message} )} {/* Alternative auth options */} - - Or authenticate with: - + Or authenticate with: - - - [C] Sync Code - + + [C] Sync Code @@ -185,19 +161,15 @@ export function LoginScreen(props: LoginScreenProps) { padding={1} backgroundColor={focusField() === "oauth" ? "#333" : undefined} > - - - [O] OAuth Info - + + [O] OAuth Info - - Tab to navigate, Enter to select - + Tab to navigate, Enter to select ) } diff --git a/src/components/OAuthPlaceholder.tsx b/src/components/OAuthPlaceholder.tsx index beb16fc..c8e99ed 100644 --- a/src/components/OAuthPlaceholder.tsx +++ b/src/components/OAuthPlaceholder.tsx @@ -38,13 +38,7 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) { } return ( - + OAuth Authentication @@ -52,18 +46,16 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) { {/* OAuth providers list */} - - Available OAuth Providers: - + Available OAuth Providers: {OAUTH_PROVIDERS.map((provider) => ( - - + + {provider.enabled ? "[+]" : "[-]"} {provider.name} - - - {provider.description} - + + - {provider.description} + ))} @@ -71,39 +63,33 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) { {/* Limitation message */} - - Terminal Limitations - + Terminal Limitations {OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => ( - - {line} - + {line} ))} {/* Alternative options */} - - Recommended Alternatives: - + Recommended Alternatives: - - [1] - Use a sync code from the web portal - - - [2] - Use email/password authentication - - - [3] - Use file-based sync (no account needed) - + + [1] + Use a sync code from the web portal + + + [2] + Use email/password authentication + + + [3] + Use file-based sync (no account needed) + @@ -115,10 +101,8 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) { padding={1} backgroundColor={focusField() === "code" ? "#333" : undefined} > - - - [C] Enter Sync Code - + + [C] Enter Sync Code @@ -127,19 +111,15 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) { padding={1} backgroundColor={focusField() === "back" ? "#333" : undefined} > - - - [Esc] Back to Login - + + [Esc] Back to Login - - Tab to navigate, Enter to select, Esc to go back - + Tab to navigate, Enter to select, Esc to go back ) } diff --git a/src/components/PodcastCard.tsx b/src/components/PodcastCard.tsx index fbcc8b3..678501e 100644 --- a/src/components/PodcastCard.tsx +++ b/src/components/PodcastCard.tsx @@ -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 */} - - - {props.podcast.title} - + + {props.podcast.title} - - [+] - + [+] {/* Author */} - - by {props.podcast.author} - + by {props.podcast.author} {/* Description */} - - - {props.podcast.description!.length > 80 - ? props.podcast.description!.slice(0, 80) + "..." - : props.podcast.description} - + + {props.podcast.description!.length > 80 + ? props.podcast.description!.slice(0, 80) + "..." + : props.podcast.description} {/* Categories and Subscribe Button */} - 0}> - {props.podcast.categories!.slice(0, 2).map((cat) => ( - - [{cat}] - + 0}> + {(props.podcast.categories ?? []).slice(0, 2).map((cat) => ( + [{cat}] ))} - - - {props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"} - + + {props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"} diff --git a/src/components/ResultCard.tsx b/src/components/ResultCard.tsx new file mode 100644 index 0000000..3a97f15 --- /dev/null +++ b/src/components/ResultCard.tsx @@ -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 ( + + + + + {podcast().title} + + + + + [Subscribed] + + + + + by {podcast().author} + + + + {(description) => ( + + {description().length > 120 + ? description().slice(0, 120) + "..." + : description()} + + )} + + + 0}> + + {(podcast().categories ?? []).slice(0, 3).map((category) => ( + [{category}] + ))} + + + + + { + event.stopPropagation?.() + props.onSubscribe?.() + }} + > + [+] Add to Feeds + + + + ) +} diff --git a/src/components/ResultDetail.tsx b/src/components/ResultDetail.tsx new file mode 100644 index 0000000..9ff3c2f --- /dev/null +++ b/src/components/ResultDetail.tsx @@ -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 ( + + Select a result to see details. + } + > + {(result) => ( + <> + + {result().podcast.title} + + + + + + by {result().podcast.author} + + + + {result().podcast.description} + + + 0}> + + {(result().podcast.categories ?? []).map((category) => ( + [{category}] + ))} + + + + Feed: {result().podcast.feedUrl} + + + Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")} + + + + props.onSubscribe?.(result())} + > + [+] Add to Feeds + + + + + Already subscribed + + + )} + + + ) +} diff --git a/src/components/SearchHistory.tsx b/src/components/SearchHistory.tsx index 1aab508..d8e36dd 100644 --- a/src/components/SearchHistory.tsx +++ b/src/components/SearchHistory.tsx @@ -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 ( - - Recent Searches - + Recent Searches 0}> props.onClear?.()} padding={0}> - - [Clear All] - + [Clear All] @@ -44,13 +39,11 @@ export function SearchHistory(props: SearchHistoryProps) { when={props.history.length > 0} fallback={ - - No recent searches - + No recent searches } > - + {(query, index) => { @@ -67,20 +60,11 @@ export function SearchHistory(props: SearchHistoryProps) { onMouseDown={() => handleSearchClick(index(), query)} > - - {">"} - - - {query} - + {">"} + {query} - handleRemoveClick(e, query)} - padding={0} - > - - [x] - + handleRemoveClick(query)} padding={0}> + [x] ) diff --git a/src/components/SearchPage.tsx b/src/components/SearchPage.tsx index 422efd2..a3880ab 100644 --- a/src/components/SearchPage.tsx +++ b/src/components/SearchPage.tsx @@ -47,6 +47,7 @@ export function SearchPage(props: SearchPageProps) { const handleResultSelect = (result: SearchResult) => { props.onSubscribe?.(result) + searchStore.markSubscribed(result.podcast.id) } // Keyboard navigation @@ -162,23 +163,24 @@ export function SearchPage(props: SearchPageProps) { {/* Search Header */} - - Search Podcasts - + + Search Podcasts + {/* Search Input */} - - Search: - + Search: { + 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)} /> - - [Enter] Search - + [Enter] Search {/* Status */} - - Searching... - + Searching... - - {searchStore.error()} - + {searchStore.error()} @@ -210,23 +206,19 @@ export function SearchPage(props: SearchPageProps) { {/* Results Panel */} - - - - Results ({searchStore.results().length}) - + + + Results ({searchStore.results().length}) 0} fallback={ - - - {searchStore.query() - ? "No results found" - : "Enter a search term to find podcasts"} - + + {searchStore.query() + ? "No results found" + : "Enter a search term to find podcasts"} } @@ -237,24 +229,24 @@ export function SearchPage(props: SearchPageProps) { focused={focusArea() === "results"} onSelect={handleResultSelect} onChange={setResultIndex} + isSearching={searchStore.isSearching()} + error={searchStore.error()} /> {/* History Sidebar */} - - - - - + + + + History - - - - + + - - [Tab] Switch focus - - - [/] Focus search - - - [Enter] Select - - - [Esc] Back to search - + [Tab] Switch focus + [/] Focus search + [Enter] Select + [Esc] Back to search ) diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index 2113f56..2d2a9a8 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -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 ( - 0} - fallback={ - - - No results found. Try a different search term. - - - } - > - - - - {(result, index) => { - const isSelected = () => index() === props.selectedIndex - const podcast = result.podcast - - return ( - handleMouseDown(index(), result)} - > - - - - {podcast.title} - - - - - [Subscribed] - - - - ({result.sourceId}) - - - - - - by {podcast.author} - - - - - - - {podcast.description!.length > 100 - ? podcast.description!.slice(0, 100) + "..." - : podcast.description} - - - - - 0}> - - - {(category) => ( - - [{category}] - - )} - - - + + Searching... + + }> + + {props.error} + + } + > + 0} + fallback={ + + No results found. Try a different search term. + + } + > + + + + + + {(result, index) => ( + handleSelect(index())} + onSubscribe={() => props.onSelect?.(result)} + /> + )} + - ) - }} - - - + + + + props.onSelect?.(result)} + /> + + + + ) } diff --git a/src/components/SourceBadge.tsx b/src/components/SourceBadge.tsx new file mode 100644 index 0000000..4e8a0c3 --- /dev/null +++ b/src/components/SourceBadge.tsx @@ -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 ( + + + [{typeLabel(props.sourceType)}] + + {label()} + + ) +} diff --git a/src/components/SourceManager.tsx b/src/components/SourceManager.tsx index c34c694..509b1c5 100644 --- a/src/components/SourceManager.tsx +++ b/src/components/SourceManager.tsx @@ -106,35 +106,21 @@ export function SourceManager(props: SourceManagerProps) { } return ( - + Podcast Sources - - [Esc] Close - + [Esc] Close - - - Manage where to search for podcasts - - + Manage where to search for podcasts {/* Source list */} - - Sources: - + Sources: {(source, index) => ( @@ -153,61 +139,43 @@ export function SourceManager(props: SourceManagerProps) { feedStore.toggleSource(source.id) }} > - - - {focusArea() === "list" && index() === selectedIndex() - ? ">" - : " "} - + + {focusArea() === "list" && index() === selectedIndex() + ? ">" + : " "} - - - {source.enabled ? "[x]" : "[ ]"} - + + {source.enabled ? "[x]" : "[ ]"} - - {getSourceIcon(source)} - - - - {source.name} - + {getSourceIcon(source)} + + {source.name} )} - - - Space/Enter to toggle, d to delete, a to add - - + Space/Enter to toggle, d to delete, a to add {/* Add new source form */} - - - Add New Source: - + + Add New Source: - - Name: - + Name: - - URL: - + URL: { @@ -239,22 +205,16 @@ export function SourceManager(props: SourceManagerProps) { width={15} onMouseDown={handleAddSource} > - - [+] Add Source - + [+] Add Source {/* Error message */} {error() && ( - - {error()} - + {error()} )} - - Tab to switch sections, Esc to close - + Tab to switch sections, Esc to close ) } diff --git a/src/components/SyncProfile.tsx b/src/components/SyncProfile.tsx index 5773d6a..a5fc966 100644 --- a/src/components/SyncProfile.tsx +++ b/src/components/SyncProfile.tsx @@ -59,13 +59,7 @@ export function SyncProfile(props: SyncProfileProps) { } return ( - + User Profile @@ -76,24 +70,14 @@ export function SyncProfile(props: SyncProfileProps) { {/* ASCII avatar */} - - {userInitials()} - + {userInitials()} {/* User details */} - - {user()?.name || "Guest User"} - - - {user()?.email || "No email"} - - - - Joined: {formatDate(user()?.createdAt)} - - + {user()?.name || "Guest User"} + {user()?.email || "No email"} + Joined: {formatDate(user()?.createdAt)} @@ -101,37 +85,23 @@ export function SyncProfile(props: SyncProfileProps) { {/* Sync status section */} - - Sync Status - + Sync Status - - Status: - - - - {user()?.syncEnabled ? "Enabled" : "Disabled"} - + Status: + + {user()?.syncEnabled ? "Enabled" : "Disabled"} - - Last Sync: - - - {formatDate(lastSyncTime())} - + Last Sync: + {formatDate(lastSyncTime())} - - Method: - - - File-based (JSON/XML) - + Method: + File-based (JSON/XML) @@ -144,10 +114,8 @@ export function SyncProfile(props: SyncProfileProps) { padding={1} backgroundColor={focusField() === "sync" ? "#333" : undefined} > - - - [S] Manage Sync - + + [S] Manage Sync @@ -156,10 +124,8 @@ export function SyncProfile(props: SyncProfileProps) { padding={1} backgroundColor={focusField() === "export" ? "#333" : undefined} > - - - [E] Export Data - + + [E] Export Data @@ -168,19 +134,15 @@ export function SyncProfile(props: SyncProfileProps) { padding={1} backgroundColor={focusField() === "logout" ? "#333" : undefined} > - - - [L] Logout - + + [L] Logout - - Tab to navigate, Enter to select - + Tab to navigate, Enter to select ) } diff --git a/src/components/TrendingShows.tsx b/src/components/TrendingShows.tsx index 85f3fb4..367ce7d 100644 --- a/src/components/TrendingShows.tsx +++ b/src/components/TrendingShows.tsx @@ -20,22 +20,18 @@ export function TrendingShows(props: TrendingShowsProps) { - - Loading trending shows... - + Loading trending shows... - - No podcasts found in this category. - + No podcasts found in this category. 0}> - + {(podcast, index) => ( diff --git a/src/stores/discover.ts b/src/stores/discover.ts index 272b21c..86f8eae 100644 --- a/src/stores/discover.ts +++ b/src/stores/discover.ts @@ -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, }, diff --git a/src/stores/feed.ts b/src/stores/feed.ts index ebdae12..2387bb9 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -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, diff --git a/src/stores/search.ts b/src/stores/search.ts index e4c41a0..ba42afd 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -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([]) @@ -131,7 +51,24 @@ export function createSearchStore() { const [history, setHistory] = createSignal(loadHistory()) const [selectedSources, setSelectedSources] = createSignal([]) - /** 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 => { 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, } } diff --git a/src/types/source.ts b/src/types/source.ts index 040b9f2..551fb45 100644 --- a/src/types/source.ts +++ b/src/types/source.ts @@ -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", diff --git a/src/utils/search.ts b/src/utils/search.ts new file mode 100644 index 0000000..08e9fc6 --- /dev/null +++ b/src/utils/search.ts @@ -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() + +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() + 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 => { + 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 => { + const trimmed = query.trim() + if (!trimmed) return [] + await new Promise((resolve) => setTimeout(resolve, 200)) + return [] +} diff --git a/src/utils/source-searcher.ts b/src/utils/source-searcher.ts new file mode 100644 index 0000000..d091b68 --- /dev/null +++ b/src/utils/source-searcher.ts @@ -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 => { + 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 => { + 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 => { + await delay(300, 650) + return makeResults(query, source, 13) +} + +export const searchSourceByType = async ( + query: string, + source: PodcastSource +): Promise => { + if (source.type === SourceType.RSS) { + return searchRSSSource(query, source) + } + if (source.type === SourceType.CUSTOM) { + return searchCustomSource(query, source) + } + return searchAPISource(query, source) +}