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)
+}