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