This commit is contained in:
Michael Freno
2026-02-04 09:39:58 -05:00
parent bd4747679d
commit f7df578461
26 changed files with 907 additions and 783 deletions

View File

@@ -11,6 +11,8 @@ import { SyncProfile } from "./components/SyncProfile"
import { SearchPage } from "./components/SearchPage"
import { DiscoverPage } from "./components/DiscoverPage"
import { useAuthStore } from "./stores/auth"
import { useFeedStore } from "./stores/feed"
import { FeedVisibility } from "./types/feed"
import { useAppKeyboard } from "./hooks/useAppKeyboard"
import type { TabId } from "./components/Tab"
import type { AuthScreen } from "./types/auth"
@@ -21,6 +23,7 @@ export function App() {
const [showAuthPanel, setShowAuthPanel] = createSignal(false)
const [inputFocused, setInputFocused] = createSignal(false)
const auth = useAuthStore()
const feedStore = useFeedStore()
// Centralized keyboard handler for all tab navigation and shortcuts
useAppKeyboard({
@@ -101,27 +104,19 @@ export function App() {
<box height={1} />
<box border padding={1}>
<box flexDirection="row" gap={2}>
<text>
<span fg="gray">Account:</span>
</text>
<text fg="gray">Account:</text>
{auth.isAuthenticated ? (
<text>
<span fg="green">Signed in as {auth.user?.email}</span>
</text>
<text fg="green">Signed in as {auth.user?.email}</text>
) : (
<text>
<span fg="yellow">Not signed in</span>
</text>
<text fg="yellow">Not signed in</text>
)}
<box
border
padding={0}
onMouseDown={() => setShowAuthPanel(true)}
>
<text>
<span fg="cyan">
<text fg="cyan">
{auth.isAuthenticated ? "[A] Account" : "[A] Sign In"}
</span>
</text>
</box>
</box>
@@ -140,8 +135,20 @@ export function App() {
focused={!inputFocused()}
onInputFocusChange={setInputFocused}
onSubscribe={(result) => {
// Would add to feeds
console.log("Subscribe to:", result.podcast.title)
const feeds = feedStore.feeds()
const alreadySubscribed = feeds.some(
(feed) =>
feed.podcast.id === result.podcast.id ||
feed.podcast.feedUrl === result.podcast.feedUrl
)
if (!alreadySubscribed) {
feedStore.addFeed(
{ ...result.podcast, isSubscribed: true },
result.sourceId,
FeedVisibility.PUBLIC
)
}
}}
/>
)
@@ -153,7 +160,7 @@ export function App() {
<text>
<strong>{tab}</strong>
<br />
<span fg="gray">Player - coming in later phases</span>
Player - coming in later phases
</text>
</box>
)

View File

@@ -28,10 +28,8 @@ export function CategoryFilter(props: CategoryFilterProps) {
backgroundColor={isSelected() ? "#444" : undefined}
onMouseDown={() => props.onSelect?.(category.id)}
>
<text>
<span fg={isSelected() ? "cyan" : "gray"}>
<text fg={isSelected() ? "cyan" : "gray"}>
{category.icon} {category.name}
</span>
</text>
</box>
)

View File

@@ -96,47 +96,27 @@ export function CodeValidation(props: CodeValidationProps) {
}
return (
<box
flexDirection="column"
border
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" border padding={2} gap={1}>
<text>
<strong>Enter Sync Code</strong>
</text>
<box height={1} />
<text>
<span fg="gray">
Enter your 8-character sync code to link your account.
</span>
</text>
<text>
<span fg="gray">
You can get this code from the web portal.
</span>
</text>
<text fg="gray">Enter your 8-character sync code to link your account.</text>
<text fg="gray">You can get this code from the web portal.</text>
<box height={1} />
{/* Code display */}
<box flexDirection="column" gap={0}>
<text>
<span fg={focusField() === "code" ? "cyan" : undefined}>
<text fg={focusField() === "code" ? "cyan" : undefined}>
Code ({codeProgress()}):
</span>
</text>
<box border padding={1}>
<text>
<span
fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}
>
<text fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}>
{codeDisplay()}
</span>
</text>
</box>
@@ -150,9 +130,7 @@ export function CodeValidation(props: CodeValidationProps) {
/>
{codeError() && (
<text>
<span fg="red">{codeError()}</span>
</text>
<text fg="red">{codeError()}</text>
)}
</box>
@@ -165,10 +143,8 @@ export function CodeValidation(props: CodeValidationProps) {
padding={1}
backgroundColor={focusField() === "submit" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "submit" ? "cyan" : undefined}>
<text fg={focusField() === "submit" ? "cyan" : undefined}>
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
</span>
</text>
</box>
@@ -177,26 +153,20 @@ export function CodeValidation(props: CodeValidationProps) {
padding={1}
backgroundColor={focusField() === "back" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "back" ? "yellow" : "gray"}>
<text fg={focusField() === "back" ? "yellow" : "gray"}>
[Esc] Back to Login
</span>
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && (
<text>
<span fg="red">{auth.error.message}</span>
</text>
<text fg="red">{auth.error.message}</text>
)}
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
</text>
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
</box>
)
}

View File

@@ -120,15 +120,11 @@ export function DiscoverPage(props: DiscoverPageProps) {
<strong>Discover Podcasts</strong>
</text>
<box flexDirection="row" gap={2}>
<text>
<span fg="gray">
<text fg="gray">
{discoverStore.filteredPodcasts().length} shows
</span>
</text>
<box onMouseDown={() => discoverStore.refresh()}>
<text>
<span fg="cyan">[R] Refresh</span>
</text>
<text fg="cyan">[R] Refresh</text>
</box>
</box>
</box>
@@ -136,10 +132,8 @@ export function DiscoverPage(props: DiscoverPageProps) {
{/* Category Filter */}
<box border padding={1}>
<box flexDirection="column" gap={1}>
<text>
<span fg={focusArea() === "categories" ? "cyan" : "gray"}>
<text fg={focusArea() === "categories" ? "cyan" : "gray"}>
Categories:
</span>
</text>
<CategoryFilter
categories={discoverStore.categories}
@@ -152,15 +146,13 @@ export function DiscoverPage(props: DiscoverPageProps) {
{/* Trending Shows */}
<box flexDirection="column" flexGrow={1} border>
<box padding={1} borderBottom>
<text>
<span fg={focusArea() === "shows" ? "cyan" : "gray"}>
<box padding={1}>
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
Trending in {
DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory()
)?.name ?? "All"
}
</span>
</text>
</box>
<TrendingShows
@@ -175,18 +167,10 @@ export function DiscoverPage(props: DiscoverPageProps) {
{/* Footer Hints */}
<box flexDirection="row" gap={2}>
<text>
<span fg="gray">[Tab] Switch focus</span>
</text>
<text>
<span fg="gray">[j/k] Navigate</span>
</text>
<text>
<span fg="gray">[Enter] Subscribe</span>
</text>
<text>
<span fg="gray">[R] Refresh</span>
</text>
<text fg="gray">[Tab] Switch focus</text>
<text fg="gray">[j/k] Navigate</text>
<text fg="gray">[Enter] Subscribe</text>
<text fg="gray">[R] Refresh</text>
</box>
</box>
)

View File

@@ -73,30 +73,14 @@ export function FeedDetail(props: FeedDetailProps) {
}
return (
<box
flexDirection="column"
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" gap={1}>
{/* Header with back button */}
<box flexDirection="row" justifyContent="space-between">
<box
border
padding={0}
onMouseDown={props.onBack}
>
<text>
<span fg="cyan">[Esc] Back</span>
</text>
<box border padding={0} onMouseDown={props.onBack}>
<text fg="cyan">[Esc] Back</text>
</box>
<box
border
padding={0}
onMouseDown={() => setShowInfo((v) => !v)}
>
<text>
<span fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</span>
</text>
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)}>
<text fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</text>
</box>
</box>
@@ -107,37 +91,31 @@ export function FeedDetail(props: FeedDetailProps) {
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</text>
{props.feed.podcast.author && (
<text>
<span fg="gray">by </span>
<span fg="cyan">{props.feed.podcast.author}</span>
</text>
<box flexDirection="row" gap={1}>
<text fg="gray">by</text>
<text fg="cyan">{props.feed.podcast.author}</text>
</box>
)}
<box height={1} />
<text>
<span fg="gray">
<text fg="gray">
{props.feed.podcast.description?.slice(0, 200)}
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
</span>
</text>
<box height={1} />
<box flexDirection="row" gap={2}>
<text>
<span fg="gray">Episodes: </span>
<span fg="white">{props.feed.episodes.length}</span>
</text>
<text>
<span fg="gray">Updated: </span>
<span fg="white">{formatDate(props.feed.lastUpdated)}</span>
</text>
<text>
<span fg={props.feed.visibility === "public" ? "green" : "yellow"}>
<box flexDirection="row" gap={1}>
<text fg="gray">Episodes:</text>
<text fg="white">{props.feed.episodes.length}</text>
</box>
<box flexDirection="row" gap={1}>
<text fg="gray">Updated:</text>
<text fg="white">{formatDate(props.feed.lastUpdated)}</text>
</box>
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</span>
</text>
{props.feed.isPinned && (
<text>
<span fg="yellow">[Pinned]</span>
</text>
<text fg="yellow">[Pinned]</text>
)}
</box>
</box>
@@ -147,8 +125,8 @@ export function FeedDetail(props: FeedDetailProps) {
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Episodes</strong>
<span fg="gray"> ({episodes().length} total)</span>
</text>
<text fg="gray">({episodes().length} total)</text>
</box>
{/* Episode list */}
@@ -168,25 +146,17 @@ export function FeedDetail(props: FeedDetailProps) {
}}
>
<box flexDirection="row" gap={1}>
<text>
<span fg={index() === selectedIndex() ? "cyan" : "gray"}>
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
{index() === selectedIndex() ? ">" : " "}
</span>
</text>
<text>
<span fg={index() === selectedIndex() ? "white" : undefined}>
<text fg={index() === selectedIndex() ? "white" : undefined}>
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
{episode.title}
</span>
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text>
<span fg="gray">{formatDate(episode.pubDate)}</span>
</text>
<text>
<span fg="gray">{formatDuration(episode.duration)}</span>
</text>
<text fg="gray">{formatDate(episode.pubDate)}</text>
<text fg="gray">{formatDuration(episode.duration)}</text>
</box>
</box>
)}
@@ -194,10 +164,8 @@ export function FeedDetail(props: FeedDetailProps) {
</scrollbox>
{/* Help text */}
<text>
<span fg="gray">
<text fg="gray">
j/k to navigate, Enter to play, i to toggle info, Esc to go back
</span>
</text>
</box>
)

View File

@@ -4,7 +4,8 @@
*/
import { createSignal } from "solid-js"
import type { FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
import { FeedVisibility, FeedSortField } from "../types/feed"
import type { FeedFilter } from "../types/feed"
interface FeedFilterProps {
filter: FeedFilter
@@ -45,14 +46,19 @@ export function FeedFilterComponent(props: FeedFilterProps) {
const cycleVisibility = () => {
const current = props.filter.visibility
let next: FeedVisibility | "all"
if (current === "all") next = "public"
else if (current === "public") next = "private"
if (current === "all") next = FeedVisibility.PUBLIC
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
else next = "all"
props.onFilterChange({ ...props.filter, visibility: next })
}
const cycleSort = () => {
const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"]
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
]
const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField)
const nextIndex = (currentIndex + 1) % sortOptions.length
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] })
@@ -100,13 +106,7 @@ export function FeedFilterComponent(props: FeedFilterProps) {
}
return (
<box
flexDirection="column"
border
padding={1}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" border padding={1} gap={1}>
<text>
<strong>Filter Feeds</strong>
</text>
@@ -118,12 +118,10 @@ export function FeedFilterComponent(props: FeedFilterProps) {
padding={0}
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "visibility" ? "cyan" : "gray"}>
Show:{" "}
</span>
<span fg={visibilityColor()}>{visibilityLabel()}</span>
</text>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>Show:</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text>
</box>
</box>
{/* Sort filter */}
@@ -132,10 +130,10 @@ export function FeedFilterComponent(props: FeedFilterProps) {
padding={0}
backgroundColor={focusField() === "sort" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "sort" ? "cyan" : "gray"}>Sort: </span>
<span fg="white">{sortLabel()}</span>
</text>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "sort" ? "cyan" : "gray"}>Sort:</text>
<text fg="white">{sortLabel()}</text>
</box>
</box>
{/* Pinned filter */}
@@ -144,22 +142,18 @@ export function FeedFilterComponent(props: FeedFilterProps) {
padding={0}
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "pinned" ? "cyan" : "gray"}>
Pinned:{" "}
</span>
<span fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>Pinned:</text>
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</span>
</text>
</box>
</box>
</box>
{/* Search box */}
<box flexDirection="row" gap={1}>
<text>
<span fg={focusField() === "search" ? "cyan" : "gray"}>Search:</span>
</text>
<text fg={focusField() === "search" ? "cyan" : "gray"}>Search:</text>
<input
value={searchValue()}
onInput={handleSearchInput}
@@ -169,9 +163,7 @@ export function FeedFilterComponent(props: FeedFilterProps) {
/>
</box>
<text>
<span fg="gray">Tab to navigate, Enter/Space to toggle</span>
</text>
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
</box>
)
}

View File

@@ -47,23 +47,15 @@ export function FeedItem(props: FeedItemProps) {
paddingLeft={1}
paddingRight={1}
>
<text>
<span fg={props.isSelected ? "cyan" : "gray"}>
<text fg={props.isSelected ? "cyan" : "gray"}>
{props.isSelected ? ">" : " "}
</span>
</text>
<text>
<span fg={visibilityColor()}>{visibilityIcon()}</span>
</text>
<text>
<span fg={props.isSelected ? "white" : undefined}>
<text fg={visibilityColor()}>{visibilityIcon()}</text>
<text fg={props.isSelected ? "white" : undefined}>
{props.feed.customName || props.feed.podcast.title}
</span>
</text>
{props.showEpisodeCount && (
<text>
<span fg="gray">({episodeCount()})</span>
</text>
<text fg="gray">({episodeCount()})</text>
)}
</box>
)
@@ -81,50 +73,34 @@ export function FeedItem(props: FeedItemProps) {
>
{/* Title row */}
<box flexDirection="row" gap={1}>
<text>
<span fg={props.isSelected ? "cyan" : "gray"}>
<text fg={props.isSelected ? "cyan" : "gray"}>
{props.isSelected ? ">" : " "}
</span>
</text>
<text>
<span fg={visibilityColor()}>{visibilityIcon()}</span>
</text>
<text>
<span fg="yellow">{pinnedIndicator()}</span>
</text>
<text>
<span fg={props.isSelected ? "white" : undefined}>
<text fg={visibilityColor()}>{visibilityIcon()}</text>
<text fg="yellow">{pinnedIndicator()}</text>
<text fg={props.isSelected ? "white" : undefined}>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</span>
</text>
</box>
{/* Details row */}
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<text>
<span fg="gray">
<text fg="gray">
{episodeCount()} episodes ({unplayedCount()} new)
</span>
</text>
)}
{props.showLastUpdated && (
<text>
<span fg="gray">
Updated: {formatDate(props.feed.lastUpdated)}
</span>
</text>
<text fg="gray">Updated: {formatDate(props.feed.lastUpdated)}</text>
)}
</box>
{/* Description (truncated) */}
{props.feed.podcast.description && (
<box paddingLeft={4} paddingTop={0}>
<text>
<span fg="gray">
<text fg="gray">
{props.feed.podcast.description.slice(0, 60)}
{props.feed.podcast.description.length > 60 ? "..." : ""}
</span>
</text>
</box>
)}

View File

@@ -6,7 +6,8 @@
import { createSignal, For, Show } from "solid-js"
import { FeedItem } from "./FeedItem"
import { useFeedStore } from "../stores/feed"
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
import { FeedVisibility, FeedSortField } from "../types/feed"
import type { Feed } from "../types/feed"
interface FeedListProps {
focused?: boolean
@@ -67,14 +68,19 @@ export function FeedList(props: FeedListProps) {
const cycleVisibilityFilter = () => {
const current = feedStore.filter().visibility
let next: FeedVisibility | "all"
if (current === "all") next = "public"
else if (current === "public") next = "private"
if (current === "all") next = FeedVisibility.PUBLIC
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
else next = "all"
feedStore.setFilter({ ...feedStore.filter(), visibility: next })
}
const cycleSortField = () => {
const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"]
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
]
const current = feedStore.filter().sortBy as FeedSortField
const idx = sortOptions.indexOf(current)
const next = sortOptions[(idx + 1) % sortOptions.length]
@@ -112,35 +118,27 @@ export function FeedList(props: FeedListProps) {
}
return (
<box
flexDirection="column"
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" gap={1}>
{/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text>
<strong>My Feeds</strong>
<span fg="gray"> ({filteredFeeds().length} feeds)</span>
</text>
<text fg="gray">({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}>
<box
border
padding={0}
onMouseDown={cycleVisibilityFilter}
>
<text>
<span fg="cyan">[f] {visibilityLabel()}</span>
</text>
<text fg="cyan">[f] {visibilityLabel()}</text>
</box>
<box
border
padding={0}
onMouseDown={cycleSortField}
>
<text>
<span fg="cyan">[s] {sortLabel()}</span>
</text>
<text fg="cyan">[s] {sortLabel()}</text>
</box>
</box>
</box>
@@ -150,25 +148,16 @@ export function FeedList(props: FeedListProps) {
when={filteredFeeds().length > 0}
fallback={
<box border padding={2}>
<text>
<span fg="gray">
<text fg="gray">
No feeds found. Add podcasts from the Discover or Search tabs.
</span>
</text>
</box>
}
>
<scrollbox
height={15}
focused={props.focused}
selectedIndex={selectedIndex()}
>
<scrollbox height={15} focused={props.focused}>
<For each={filteredFeeds()}>
{(feed, index) => (
<box
onMouseDown={() => handleFeedClick(feed, index())}
onDoubleClick={() => handleFeedDoubleClick(feed)}
>
<box onMouseDown={() => handleFeedClick(feed, index())}>
<FeedItem
feed={feed}
isSelected={index() === selectedIndex()}
@@ -184,10 +173,8 @@ export function FeedList(props: FeedListProps) {
{/* Navigation help */}
<box paddingTop={0}>
<text>
<span fg="gray">
<text fg="gray">
j/k navigate | Enter open | p pin | f filter | s sort | Click to select
</span>
</text>
</box>
</box>

View File

@@ -81,13 +81,7 @@ export function LoginScreen(props: LoginScreenProps) {
}
return (
<box
flexDirection="column"
border
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" border padding={2} gap={1}>
<text>
<strong>Sign In</strong>
</text>
@@ -96,11 +90,7 @@ export function LoginScreen(props: LoginScreenProps) {
{/* Email field */}
<box flexDirection="column" gap={0}>
<text>
<span fg={focusField() === "email" ? "cyan" : undefined}>
Email:
</span>
</text>
<text fg={focusField() === "email" ? "cyan" : undefined}>Email:</text>
<input
value={email()}
onInput={setEmail}
@@ -109,18 +99,14 @@ export function LoginScreen(props: LoginScreenProps) {
width={30}
/>
{emailError() && (
<text>
<span fg="red">{emailError()}</span>
</text>
<text fg="red">{emailError()}</text>
)}
</box>
{/* Password field */}
<box flexDirection="column" gap={0}>
<text>
<span fg={focusField() === "password" ? "cyan" : undefined}>
<text fg={focusField() === "password" ? "cyan" : undefined}>
Password:
</span>
</text>
<input
value={password()}
@@ -130,9 +116,7 @@ export function LoginScreen(props: LoginScreenProps) {
width={30}
/>
{passwordError() && (
<text>
<span fg="red">{passwordError()}</span>
</text>
<text fg="red">{passwordError()}</text>
)}
</box>
@@ -145,27 +129,21 @@ export function LoginScreen(props: LoginScreenProps) {
padding={1}
backgroundColor={focusField() === "submit" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "submit" ? "cyan" : undefined}>
<text fg={focusField() === "submit" ? "cyan" : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
</span>
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && (
<text>
<span fg="red">{auth.error.message}</span>
</text>
<text fg="red">{auth.error.message}</text>
)}
<box height={1} />
{/* Alternative auth options */}
<text>
<span fg="gray">Or authenticate with:</span>
</text>
<text fg="gray">Or authenticate with:</text>
<box flexDirection="row" gap={2}>
<box
@@ -173,10 +151,8 @@ export function LoginScreen(props: LoginScreenProps) {
padding={1}
backgroundColor={focusField() === "code" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "code" ? "yellow" : "gray"}>
<text fg={focusField() === "code" ? "yellow" : "gray"}>
[C] Sync Code
</span>
</text>
</box>
@@ -185,19 +161,15 @@ export function LoginScreen(props: LoginScreenProps) {
padding={1}
backgroundColor={focusField() === "oauth" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "oauth" ? "yellow" : "gray"}>
<text fg={focusField() === "oauth" ? "yellow" : "gray"}>
[O] OAuth Info
</span>
</text>
</box>
</box>
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select</span>
</text>
<text fg="gray">Tab to navigate, Enter to select</text>
</box>
)
}

View File

@@ -38,13 +38,7 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
}
return (
<box
flexDirection="column"
border
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" border padding={2} gap={1}>
<text>
<strong>OAuth Authentication</strong>
</text>
@@ -52,18 +46,16 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
<box height={1} />
{/* OAuth providers list */}
<text>
<span fg="cyan">Available OAuth Providers:</span>
</text>
<text fg="cyan">Available OAuth Providers:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
{OAUTH_PROVIDERS.map((provider) => (
<text>
<span fg={provider.enabled ? "green" : "gray"}>
<box flexDirection="row" gap={1}>
<text fg={provider.enabled ? "green" : "gray"}>
{provider.enabled ? "[+]" : "[-]"} {provider.name}
</span>
<span fg="gray"> - {provider.description}</span>
</text>
<text fg="gray">- {provider.description}</text>
</box>
))}
</box>
@@ -71,39 +63,33 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
{/* Limitation message */}
<box border padding={1} borderColor="yellow">
<text>
<span fg="yellow">Terminal Limitations</span>
</text>
<text fg="yellow">Terminal Limitations</text>
</box>
<box paddingLeft={1}>
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
<text>
<span fg="gray">{line}</span>
</text>
<text fg="gray">{line}</text>
))}
</box>
<box height={1} />
{/* Alternative options */}
<text>
<span fg="cyan">Recommended Alternatives:</span>
</text>
<text fg="cyan">Recommended Alternatives:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
<text>
<span fg="green">[1]</span>
<span fg="white"> Use a sync code from the web portal</span>
</text>
<text>
<span fg="green">[2]</span>
<span fg="white"> Use email/password authentication</span>
</text>
<text>
<span fg="green">[3]</span>
<span fg="white"> Use file-based sync (no account needed)</span>
</text>
<box flexDirection="row" gap={1}>
<text fg="green">[1]</text>
<text fg="white">Use a sync code from the web portal</text>
</box>
<box flexDirection="row" gap={1}>
<text fg="green">[2]</text>
<text fg="white">Use email/password authentication</text>
</box>
<box flexDirection="row" gap={1}>
<text fg="green">[3]</text>
<text fg="white">Use file-based sync (no account needed)</text>
</box>
</box>
<box height={1} />
@@ -115,10 +101,8 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
padding={1}
backgroundColor={focusField() === "code" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "code" ? "cyan" : undefined}>
<text fg={focusField() === "code" ? "cyan" : undefined}>
[C] Enter Sync Code
</span>
</text>
</box>
@@ -127,19 +111,15 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
padding={1}
backgroundColor={focusField() === "back" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "back" ? "yellow" : "gray"}>
<text fg={focusField() === "back" ? "yellow" : "gray"}>
[Esc] Back to Login
</span>
</text>
</box>
</box>
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
</text>
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
</box>
)
}

View File

@@ -14,8 +14,7 @@ type PodcastCardProps = {
}
export function PodcastCard(props: PodcastCardProps) {
const handleSubscribeClick = (e: MouseEvent) => {
e.stopPropagation?.()
const handleSubscribeClick = () => {
props.onSubscribe?.()
}
@@ -28,55 +27,43 @@ export function PodcastCard(props: PodcastCardProps) {
>
{/* Title Row */}
<box flexDirection="row" gap={2} alignItems="center">
<text>
<span fg={props.selected ? "cyan" : "white"}>
<text fg={props.selected ? "cyan" : "white"}>
<strong>{props.podcast.title}</strong>
</span>
</text>
<Show when={props.podcast.isSubscribed}>
<text>
<span fg="green">[+]</span>
</text>
<text fg="green">[+]</text>
</Show>
</box>
{/* Author */}
<Show when={props.podcast.author && !props.compact}>
<text>
<span fg="gray">by {props.podcast.author}</span>
</text>
<text fg="gray">by {props.podcast.author}</text>
</Show>
{/* Description */}
<Show when={props.podcast.description && !props.compact}>
<text>
<span fg={props.selected ? "white" : "gray"}>
<text fg={props.selected ? "white" : "gray"}>
{props.podcast.description!.length > 80
? props.podcast.description!.slice(0, 80) + "..."
: props.podcast.description}
</span>
</text>
</Show>
{/* Categories and Subscribe Button */}
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}>
<box flexDirection="row" gap={1}>
<Show when={props.podcast.categories && props.podcast.categories.length > 0}>
{props.podcast.categories!.slice(0, 2).map((cat) => (
<text>
<span fg="yellow">[{cat}]</span>
</text>
<Show when={(props.podcast.categories ?? []).length > 0}>
{(props.podcast.categories ?? []).slice(0, 2).map((cat) => (
<text fg="yellow">[{cat}]</text>
))}
</Show>
</box>
<Show when={props.selected}>
<box onMouseDown={handleSubscribeClick}>
<text>
<span fg={props.podcast.isSubscribed ? "red" : "green"}>
<text fg={props.podcast.isSubscribed ? "red" : "green"}>
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
</span>
</text>
</box>
</Show>

View File

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

View File

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

View File

@@ -20,22 +20,17 @@ export function SearchHistory(props: SearchHistoryProps) {
props.onSelect?.(query)
}
const handleRemoveClick = (e: MouseEvent, query: string) => {
e.stopPropagation?.()
const handleRemoveClick = (query: string) => {
props.onRemove?.(query)
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text>
<span fg="gray">Recent Searches</span>
</text>
<text fg="gray">Recent Searches</text>
<Show when={props.history.length > 0}>
<box onMouseDown={() => props.onClear?.()} padding={0}>
<text>
<span fg="red">[Clear All]</span>
</text>
<text fg="red">[Clear All]</text>
</box>
</Show>
</box>
@@ -44,13 +39,11 @@ export function SearchHistory(props: SearchHistoryProps) {
when={props.history.length > 0}
fallback={
<box padding={1}>
<text>
<span fg="gray">No recent searches</span>
</text>
<text fg="gray">No recent searches</text>
</box>
}
>
<scrollbox height={10} showScrollIndicator>
<scrollbox height={10}>
<box flexDirection="column">
<For each={props.history}>
{(query, index) => {
@@ -67,20 +60,11 @@ export function SearchHistory(props: SearchHistoryProps) {
onMouseDown={() => handleSearchClick(index(), query)}
>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">{">"}</span>
</text>
<text>
<span fg={isSelected() ? "cyan" : "white"}>{query}</span>
</text>
<text fg="gray">{">"}</text>
<text fg={isSelected() ? "cyan" : "white"}>{query}</text>
</box>
<box
onMouseDown={(e: MouseEvent) => handleRemoveClick(e, query)}
padding={0}
>
<text>
<span fg="red">[x]</span>
</text>
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
<text fg="red">[x]</text>
</box>
</box>
)

View File

@@ -47,6 +47,7 @@ export function SearchPage(props: SearchPageProps) {
const handleResultSelect = (result: SearchResult) => {
props.onSubscribe?.(result)
searchStore.markSubscribed(result.podcast.id)
}
// Keyboard navigation
@@ -168,17 +169,18 @@ export function SearchPage(props: SearchPageProps) {
{/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center">
<text>
<span fg="gray">Search:</span>
</text>
<text fg="gray">Search:</text>
<input
value={inputValue()}
onInput={setInputValue}
onInput={(value) => {
setInputValue(value)
if (props.focused && focusArea() === "input") {
props.onInputFocusChange?.(true)
}
}}
placeholder="Enter podcast name, topic, or author..."
focused={props.focused && focusArea() === "input"}
width={50}
onFocus={() => props.onInputFocusChange?.(true)}
onBlur={() => props.onInputFocusChange?.(false)}
/>
<box
border
@@ -187,22 +189,16 @@ export function SearchPage(props: SearchPageProps) {
paddingRight={1}
onMouseDown={handleSearch}
>
<text>
<span fg="cyan">[Enter] Search</span>
</text>
<text fg="cyan">[Enter] Search</text>
</box>
</box>
{/* Status */}
<Show when={searchStore.isSearching()}>
<text>
<span fg="yellow">Searching...</span>
</text>
<text fg="yellow">Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text>
<span fg="red">{searchStore.error()}</span>
</text>
<text fg="red">{searchStore.error()}</text>
</Show>
</box>
@@ -210,23 +206,19 @@ export function SearchPage(props: SearchPageProps) {
<box flexDirection="row" height="100%" gap={2}>
{/* Results Panel */}
<box flexDirection="column" flexGrow={1} border>
<box padding={1} borderBottom>
<text>
<span fg={focusArea() === "results" ? "cyan" : "gray"}>
<box padding={1}>
<text fg={focusArea() === "results" ? "cyan" : "gray"}>
Results ({searchStore.results().length})
</span>
</text>
</box>
<Show
when={searchStore.results().length > 0}
fallback={
<box padding={2}>
<text>
<span fg="gray">
<text fg="gray">
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</span>
</text>
</box>
}
@@ -237,6 +229,8 @@ export function SearchPage(props: SearchPageProps) {
focused={focusArea() === "results"}
onSelect={handleResultSelect}
onChange={setResultIndex}
isSearching={searchStore.isSearching()}
error={searchStore.error()}
/>
</Show>
</box>
@@ -244,11 +238,9 @@ export function SearchPage(props: SearchPageProps) {
{/* History Sidebar */}
<box width={30} border>
<box padding={1} flexDirection="column">
<box borderBottom paddingBottom={1}>
<text>
<span fg={focusArea() === "history" ? "cyan" : "gray"}>
<box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
History
</span>
</text>
</box>
<SearchHistory
@@ -266,18 +258,10 @@ export function SearchPage(props: SearchPageProps) {
{/* Footer Hints */}
<box flexDirection="row" gap={2}>
<text>
<span fg="gray">[Tab] Switch focus</span>
</text>
<text>
<span fg="gray">[/] Focus search</span>
</text>
<text>
<span fg="gray">[Enter] Select</span>
</text>
<text>
<span fg="gray">[Esc] Back to search</span>
</text>
<text fg="gray">[Tab] Switch focus</text>
<text fg="gray">[/] Focus search</text>
<text fg="gray">[Enter] Select</text>
<text fg="gray">[Esc] Back to search</text>
</box>
</box>
)

View File

@@ -4,6 +4,8 @@
import { For, Show } from "solid-js"
import type { SearchResult } from "../types/source"
import { ResultCard } from "./ResultCard"
import { ResultDetail } from "./ResultDetail"
type SearchResultsProps = {
results: SearchResult[]
@@ -11,88 +13,63 @@ type SearchResultsProps = {
focused: boolean
onSelect?: (result: SearchResult) => void
onChange?: (index: number) => void
isSearching?: boolean
error?: string | null
}
export function SearchResults(props: SearchResultsProps) {
const handleMouseDown = (index: number, result: SearchResult) => {
const handleSelect = (index: number) => {
props.onChange?.(index)
props.onSelect?.(result)
}
return (
<Show when={!props.isSearching} fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}>
<Show
when={!props.error}
fallback={
<box padding={1}>
<text fg="red">{props.error}</text>
</box>
}
>
<Show
when={props.results.length > 0}
fallback={
<box padding={1}>
<text>
<span fg="gray">No results found. Try a different search term.</span>
</text>
<text fg="gray">No results found. Try a different search term.</text>
</box>
}
>
<scrollbox height="100%" showScrollIndicator>
<box flexDirection="column">
<box flexDirection="row" gap={1} height="100%">
<box flexDirection="column" flexGrow={1}>
<scrollbox height="100%">
<box flexDirection="column" gap={1}>
<For each={props.results}>
{(result, index) => {
const isSelected = () => index() === props.selectedIndex
const podcast = result.podcast
return (
<box
flexDirection="column"
padding={1}
backgroundColor={isSelected() ? "#333" : undefined}
onMouseDown={() => handleMouseDown(index(), result)}
>
<box flexDirection="row" gap={2}>
<text>
<span fg={isSelected() ? "cyan" : "white"}>
<strong>{podcast.title}</strong>
</span>
</text>
<Show when={podcast.isSubscribed}>
<text>
<span fg="green">[Subscribed]</span>
</text>
</Show>
<text>
<span fg="gray">({result.sourceId})</span>
</text>
</box>
<Show when={podcast.author}>
<text>
<span fg="gray">by {podcast.author}</span>
</text>
</Show>
<Show when={podcast.description}>
<text>
<span fg={isSelected() ? "white" : "gray"}>
{podcast.description!.length > 100
? podcast.description!.slice(0, 100) + "..."
: podcast.description}
</span>
</text>
</Show>
<Show when={podcast.categories && podcast.categories.length > 0}>
<box flexDirection="row" gap={1}>
<For each={podcast.categories!.slice(0, 3)}>
{(category) => (
<text>
<span fg="yellow">[{category}]</span>
</text>
{(result, index) => (
<ResultCard
result={result}
selected={index() === props.selectedIndex}
onSelect={() => handleSelect(index())}
onSubscribe={() => props.onSelect?.(result)}
/>
)}
</For>
</box>
</Show>
</box>
)
}}
</For>
</box>
</scrollbox>
</box>
<box width={36}>
<ResultDetail
result={props.results[props.selectedIndex]}
onSubscribe={(result) => props.onSelect?.(result)}
/>
</box>
</box>
</Show>
</Show>
</Show>
)
}

View File

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

View File

@@ -106,35 +106,21 @@ export function SourceManager(props: SourceManagerProps) {
}
return (
<box
flexDirection="column"
border
padding={1}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" border padding={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Podcast Sources</strong>
</text>
<box border padding={0} onMouseDown={props.onClose}>
<text>
<span fg="cyan">[Esc] Close</span>
</text>
<text fg="cyan">[Esc] Close</text>
</box>
</box>
<text>
<span fg="gray">
Manage where to search for podcasts
</span>
</text>
<text fg="gray">Manage where to search for podcasts</text>
{/* Source list */}
<box border padding={1} flexDirection="column">
<text>
<span fg={focusArea() === "list" ? "cyan" : "gray"}>Sources:</span>
</text>
<text fg={focusArea() === "list" ? "cyan" : "gray"}>Sources:</text>
<scrollbox height={6}>
<For each={sources()}>
{(source, index) => (
@@ -153,29 +139,20 @@ export function SourceManager(props: SourceManagerProps) {
feedStore.toggleSource(source.id)
}}
>
<text>
<span
fg={
<text fg={
focusArea() === "list" && index() === selectedIndex()
? "cyan"
: "gray"
}
>
}>
{focusArea() === "list" && index() === selectedIndex()
? ">"
: " "}
</span>
</text>
<text>
<span fg={source.enabled ? "green" : "red"}>
<text fg={source.enabled ? "green" : "red"}>
{source.enabled ? "[x]" : "[ ]"}
</span>
</text>
<text>
<span fg="yellow">{getSourceIcon(source)}</span>
</text>
<text>
<span
<text fg="yellow">{getSourceIcon(source)}</text>
<text
fg={
focusArea() === "list" && index() === selectedIndex()
? "white"
@@ -183,31 +160,22 @@ export function SourceManager(props: SourceManagerProps) {
}
>
{source.name}
</span>
</text>
</box>
)}
</For>
</scrollbox>
<text>
<span fg="gray">
Space/Enter to toggle, d to delete, a to add
</span>
</text>
<text fg="gray">Space/Enter to toggle, d to delete, a to add</text>
</box>
{/* Add new source form */}
<box border padding={1} flexDirection="column" gap={1}>
<text>
<span fg={focusArea() === "add" || focusArea() === "url" ? "cyan" : "gray"}>
<text fg={focusArea() === "add" || focusArea() === "url" ? "cyan" : "gray"}>
Add New Source:
</span>
</text>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">Name:</span>
</text>
<text fg="gray">Name:</text>
<input
value={newSourceName()}
onInput={setNewSourceName}
@@ -218,9 +186,7 @@ export function SourceManager(props: SourceManagerProps) {
</box>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">URL:</span>
</text>
<text fg="gray">URL:</text>
<input
value={newSourceUrl()}
onInput={(v) => {
@@ -239,22 +205,16 @@ export function SourceManager(props: SourceManagerProps) {
width={15}
onMouseDown={handleAddSource}
>
<text>
<span fg="green">[+] Add Source</span>
</text>
<text fg="green">[+] Add Source</text>
</box>
</box>
{/* Error message */}
{error() && (
<text>
<span fg="red">{error()}</span>
</text>
<text fg="red">{error()}</text>
)}
<text>
<span fg="gray">Tab to switch sections, Esc to close</span>
</text>
<text fg="gray">Tab to switch sections, Esc to close</text>
</box>
)
}

View File

@@ -59,13 +59,7 @@ export function SyncProfile(props: SyncProfileProps) {
}
return (
<box
flexDirection="column"
border
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<box flexDirection="column" border padding={2} gap={1}>
<text>
<strong>User Profile</strong>
</text>
@@ -76,24 +70,14 @@ export function SyncProfile(props: SyncProfileProps) {
<box flexDirection="row" gap={2}>
{/* ASCII avatar */}
<box border padding={1} width={8} height={4} justifyContent="center" alignItems="center">
<text>
<span fg="cyan">{userInitials()}</span>
</text>
<text fg="cyan">{userInitials()}</text>
</box>
{/* User details */}
<box flexDirection="column" gap={0}>
<text>
<span fg="white">{user()?.name || "Guest User"}</span>
</text>
<text>
<span fg="gray">{user()?.email || "No email"}</span>
</text>
<text>
<span fg="gray">
Joined: {formatDate(user()?.createdAt)}
</span>
</text>
<text fg="white">{user()?.name || "Guest User"}</text>
<text fg="gray">{user()?.email || "No email"}</text>
<text fg="gray">Joined: {formatDate(user()?.createdAt)}</text>
</box>
</box>
@@ -101,37 +85,23 @@ export function SyncProfile(props: SyncProfileProps) {
{/* Sync status section */}
<box border padding={1} flexDirection="column" gap={0}>
<text>
<span fg="cyan">Sync Status</span>
</text>
<text fg="cyan">Sync Status</text>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">Status:</span>
</text>
<text>
<span fg={user()?.syncEnabled ? "green" : "yellow"}>
<text fg="gray">Status:</text>
<text fg={user()?.syncEnabled ? "green" : "yellow"}>
{user()?.syncEnabled ? "Enabled" : "Disabled"}
</span>
</text>
</box>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">Last Sync:</span>
</text>
<text>
<span fg="white">{formatDate(lastSyncTime())}</span>
</text>
<text fg="gray">Last Sync:</text>
<text fg="white">{formatDate(lastSyncTime())}</text>
</box>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">Method:</span>
</text>
<text>
<span fg="white">File-based (JSON/XML)</span>
</text>
<text fg="gray">Method:</text>
<text fg="white">File-based (JSON/XML)</text>
</box>
</box>
@@ -144,10 +114,8 @@ export function SyncProfile(props: SyncProfileProps) {
padding={1}
backgroundColor={focusField() === "sync" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "sync" ? "cyan" : undefined}>
<text fg={focusField() === "sync" ? "cyan" : undefined}>
[S] Manage Sync
</span>
</text>
</box>
@@ -156,10 +124,8 @@ export function SyncProfile(props: SyncProfileProps) {
padding={1}
backgroundColor={focusField() === "export" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "export" ? "cyan" : undefined}>
<text fg={focusField() === "export" ? "cyan" : undefined}>
[E] Export Data
</span>
</text>
</box>
@@ -168,19 +134,15 @@ export function SyncProfile(props: SyncProfileProps) {
padding={1}
backgroundColor={focusField() === "logout" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "logout" ? "red" : "gray"}>
<text fg={focusField() === "logout" ? "red" : "gray"}>
[L] Logout
</span>
</text>
</box>
</box>
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select</span>
</text>
<text fg="gray">Tab to navigate, Enter to select</text>
</box>
)
}

View File

@@ -20,22 +20,18 @@ export function TrendingShows(props: TrendingShowsProps) {
<box flexDirection="column" height="100%">
<Show when={props.isLoading}>
<box padding={2}>
<text>
<span fg="yellow">Loading trending shows...</span>
</text>
<text fg="yellow">Loading trending shows...</text>
</box>
</Show>
<Show when={!props.isLoading && props.podcasts.length === 0}>
<box padding={2}>
<text>
<span fg="gray">No podcasts found in this category.</span>
</text>
<text fg="gray">No podcasts found in this category.</text>
</box>
</Show>
<Show when={!props.isLoading && props.podcasts.length > 0}>
<scrollbox height="100%" showScrollIndicator>
<scrollbox height="100%">
<box flexDirection="column">
<For each={props.podcasts}>
{(podcast, index) => (

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -4,8 +4,9 @@
*/
import { createSignal } from "solid-js"
import type { Podcast } from "../types/podcast"
import type { PodcastSource, SearchResult } from "../types/source"
import { searchPodcasts } from "../utils/search"
import { useFeedStore } from "./feed"
import type { SearchResult } from "../types/source"
const STORAGE_KEY = "podtui_search_history"
const MAX_HISTORY = 20
@@ -17,89 +18,7 @@ export interface SearchState {
error: string | null
}
/** Mock search results for demonstration */
const MOCK_PODCASTS: Podcast[] = [
{
id: "search-1",
title: "Tech Talk Daily",
description: "Daily technology news and analysis from Silicon Valley experts.",
feedUrl: "https://example.com/techtalk.rss",
author: "Tech Media Group",
categories: ["Technology", "News"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "search-2",
title: "The Science Hour",
description: "Weekly deep dives into the latest scientific discoveries and research.",
feedUrl: "https://example.com/sciencehour.rss",
author: "Science Network",
categories: ["Science", "Education"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "search-3",
title: "History Lessons",
description: "Fascinating stories from history that shaped our world.",
feedUrl: "https://example.com/historylessons.rss",
author: "History Channel",
categories: ["History", "Education"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "search-4",
title: "Business Insights",
description: "Expert analysis on business trends, markets, and entrepreneurship.",
feedUrl: "https://example.com/businessinsights.rss",
author: "Business Weekly",
categories: ["Business", "Finance"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "search-5",
title: "True Crime Stories",
description: "In-depth investigations into real criminal cases and mysteries.",
feedUrl: "https://example.com/truecrime.rss",
author: "Crime Network",
categories: ["True Crime", "Documentary"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "search-6",
title: "Comedy Hour",
description: "Stand-up comedy, sketches, and hilarious conversations.",
feedUrl: "https://example.com/comedyhour.rss",
author: "Laugh Factory",
categories: ["Comedy", "Entertainment"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "search-7",
title: "Mindful Living",
description: "Meditation, wellness, and mental health tips for a better life.",
feedUrl: "https://example.com/mindful.rss",
author: "Wellness Media",
categories: ["Health", "Self-Help"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "search-8",
title: "Sports Central",
description: "Coverage of all major sports, analysis, and athlete interviews.",
feedUrl: "https://example.com/sportscentral.rss",
author: "Sports Network",
categories: ["Sports", "News"],
lastUpdated: new Date(),
isSubscribed: false,
},
]
const CACHE_TTL = 1000 * 60 * 5
/** Load search history from localStorage */
function loadHistory(): string[] {
@@ -124,6 +43,7 @@ function saveHistory(history: string[]): void {
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore()
const [query, setQuery] = createSignal("")
const [isSearching, setIsSearching] = createSignal(false)
const [results, setResults] = createSignal<SearchResult[]>([])
@@ -131,7 +51,24 @@ export function createSearchStore() {
const [history, setHistory] = createSignal<string[]>(loadHistory())
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
/** Perform search (mock implementation) */
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds()
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl))
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id))
return items.map((item) => ({
...item,
podcast: {
...item.podcast,
isSubscribed:
item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id),
},
}))
}
/** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim()
if (!q) {
@@ -146,28 +83,18 @@ export function createSearchStore() {
// Add to history
addToHistory(q)
// Simulate network delay
await new Promise((r) => setTimeout(r, 300 + Math.random() * 500))
try {
// Mock search - filter by query
const queryLower = q.toLowerCase()
const matchingPodcasts = MOCK_PODCASTS.filter(
(p) =>
p.title.toLowerCase().includes(queryLower) ||
p.description.toLowerCase().includes(queryLower) ||
p.categories?.some((c) => c.toLowerCase().includes(queryLower)) ||
p.author?.toLowerCase().includes(queryLower)
)
const sources = feedStore.sources()
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id)
const sourceIds = selectedSources().length > 0
? selectedSources()
: enabledSourceIds
// Convert to search results
const searchResults: SearchResult[] = matchingPodcasts.map((podcast, i) => ({
sourceId: i % 2 === 0 ? "itunes" : "rss",
podcast,
score: 1 - i * 0.1, // Mock relevance score
}))
const searchResults = await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
})
setResults(searchResults)
setResults(applySubscribedStatus(searchResults))
} catch (e) {
setError("Search failed. Please try again.")
setResults([])
@@ -209,6 +136,26 @@ export function createSearchStore() {
setError(null)
}
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
const matchesId = result.podcast.id === podcastId
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
}
}
return result
})
)
}
return {
// State
query,
@@ -225,6 +172,7 @@ export function createSearchStore() {
clearHistory,
removeFromHistory,
setSelectedSources,
markSubscribed,
}
}

View File

@@ -30,6 +30,14 @@ export interface PodcastSource {
iconUrl?: string
/** Source description */
description?: string
/** Default country for source searches */
country?: string
/** Default language for search results */
language?: string
/** Default results limit */
searchLimit?: number
/** Include explicit results */
allowExplicit?: boolean
/** Rate limit (requests per minute) */
rateLimit?: number
/** Last successful fetch */
@@ -76,6 +84,10 @@ export enum SearchSortField {
export interface SearchResult {
/** Source that returned this result */
sourceId: string
/** Source display name */
sourceName?: string
/** Source type */
sourceType?: SourceType
/** Podcast data */
podcast: import("./podcast").Podcast
/** Relevance score (0-1) */
@@ -91,6 +103,10 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
baseUrl: "https://itunes.apple.com/search",
enabled: true,
description: "Search the Apple Podcasts directory",
country: "US",
language: "en_us",
searchLimit: 25,
allowExplicit: true,
},
{
id: "rss",

91
src/utils/search.ts Normal file
View File

@@ -0,0 +1,91 @@
import { searchSourceByType } from "./source-searcher"
import type { PodcastSource, SearchResult } from "../types/source"
import type { Episode } from "../types/episode"
type SearchCacheEntry = {
timestamp: number
results: SearchResult[]
}
type SearchOptions = {
cacheTtl?: number
}
const searchCache = new Map<string, SearchCacheEntry>()
const buildCacheKey = (query: string, sourceIds: string[]) => {
const keySources = [...sourceIds].sort().join(",")
return `${query.toLowerCase()}::${keySources}`
}
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
Date.now() - entry.timestamp < ttl
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
const map = new Map<string, SearchResult>()
for (const result of results) {
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title
const existing = map.get(key)
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
map.set(key, result)
}
}
return Array.from(map.values())
}
export const searchPodcasts = async (
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {}
): Promise<SearchResult[]> => {
const trimmed = query.trim()
if (!trimmed) return []
const activeSources = sources.filter(
(source) => sourceIds.includes(source.id) && source.enabled
)
if (activeSources.length === 0) return []
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id))
const cached = searchCache.get(cacheKey)
if (cached && isCacheValid(cached, cacheTtl)) {
return cached.results
}
const results: SearchResult[] = []
const errors: Error[] = []
await Promise.all(
activeSources.map(async (source) => {
try {
const sourceResults = await searchSourceByType(trimmed, source)
results.push(...sourceResults)
} catch (error) {
errors.push(error as Error)
}
})
)
const deduped = dedupeResults(results)
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
if (sorted.length === 0 && errors.length > 0) {
throw new Error("Search failed for all sources")
}
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted })
return sorted
}
export const searchEpisodes = async (
query: string,
_feedId: string
): Promise<Episode[]> => {
const trimmed = query.trim()
if (!trimmed) return []
await new Promise((resolve) => setTimeout(resolve, 200))
return []
}

View File

@@ -0,0 +1,196 @@
import type { Podcast } from "../types/podcast"
import { SourceType } from "../types/source"
import type { PodcastSource, SearchResult } from "../types/source"
type SearcherResult = SearchResult[]
const delay = async (min = 200, max = 500) =>
new Promise((resolve) => setTimeout(resolve, min + Math.random() * max))
const hashString = (input: string): number => {
let hash = 0
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
const slugify = (input: string): string =>
input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
const sourceLabel = (source: PodcastSource): string =>
source.name || source.id
const buildPodcast = (
idBase: string,
title: string,
description: string,
author: string,
categories: string[],
source: PodcastSource
): Podcast => ({
id: idBase,
title,
description,
feedUrl: `https://example.com/${slugify(title)}/feed.xml`,
author,
categories,
lastUpdated: new Date(),
isSubscribed: false,
})
const makeResults = (query: string, source: PodcastSource, seedOffset = 0): SearcherResult => {
const seed = hashString(`${source.id}:${query}`) + seedOffset
const baseTitles = [
"Daily Briefing",
"Studio Sessions",
"Signal & Noise",
"The Long Play",
"Off the Record",
]
const descriptors = [
"Deep dives into",
"A fast-paced look at",
"Smart conversations about",
"A weekly roundup of",
"Curated stories on",
]
const categories = ["Technology", "Business", "Science", "Culture", "News"]
return baseTitles.map((base, index) => {
const title = `${query} ${base}`
const desc = `${descriptors[index % descriptors.length]} ${query.toLowerCase()} from ${sourceLabel(source)}.`
const author = `${sourceLabel(source)} Network`
const cat = [categories[(seed + index) % categories.length]]
const podcast = buildPodcast(
`search-${source.id}-${seed + index}`,
title,
desc,
author,
cat,
source
)
return {
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
podcast,
score: 1 - index * 0.08,
}
})
}
export const searchRSSSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(200, 450)
return makeResults(query, source, 1)
}
type ItunesResult = {
collectionId?: number
collectionName?: string
artistName?: string
feedUrl?: string
artworkUrl100?: string
artworkUrl600?: string
primaryGenreName?: string
releaseDate?: string
}
type ItunesResponse = {
resultCount: number
results: ItunesResult[]
}
const buildItunesUrl = (query: string, source: PodcastSource) => {
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
const url = new URL(baseUrl)
const params = url.searchParams
params.set("term", query.trim())
params.set("media", "podcast")
params.set("entity", "podcast")
params.set("limit", String(source.searchLimit ?? 25))
params.set("country", source.country ?? "US")
params.set("lang", source.language ?? "en_us")
params.set("explicit", source.allowExplicit === false ? "No" : "Yes")
return url.toString()
}
const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
if (!result.collectionName || !result.feedUrl) return null
const id = result.collectionId
? `itunes-${result.collectionId}`
: `itunes-${slugify(result.collectionName)}`
const descriptionParts = [result.collectionName]
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
return {
id,
title: result.collectionName,
description: descriptionParts.join(" • "),
feedUrl: result.feedUrl,
author: result.artistName,
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
coverUrl: result.artworkUrl600 || result.artworkUrl100,
lastUpdated: result.releaseDate ? new Date(result.releaseDate) : new Date(),
isSubscribed: false,
}
}
export const searchAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
const url = buildItunesUrl(query, source)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`iTunes search failed: ${response.status}`)
}
const data = (await response.json()) as ItunesResponse
const results = data.results
.map((item) => mapItunesResult(item, source))
.filter((item): item is Podcast => Boolean(item))
return results.map((podcast, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
podcast,
score: 1 - index * 0.02,
}))
}
export const searchCustomSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(300, 650)
return makeResults(query, source, 13)
}
export const searchSourceByType = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.type === SourceType.RSS) {
return searchRSSSource(query, source)
}
if (source.type === SourceType.CUSTOM) {
return searchCustomSource(query, source)
}
return searchAPISource(query, source)
}