fix keyboard, finish 05
This commit is contained in:
91
src/App.tsx
91
src/App.tsx
@@ -2,76 +2,39 @@ import { createSignal } from "solid-js"
|
|||||||
import { Layout } from "./components/Layout"
|
import { Layout } from "./components/Layout"
|
||||||
import { Navigation } from "./components/Navigation"
|
import { Navigation } from "./components/Navigation"
|
||||||
import { TabNavigation } from "./components/TabNavigation"
|
import { TabNavigation } from "./components/TabNavigation"
|
||||||
import { KeyboardHandler } from "./components/KeyboardHandler"
|
|
||||||
import { SyncPanel } from "./components/SyncPanel"
|
import { SyncPanel } from "./components/SyncPanel"
|
||||||
import { FeedList } from "./components/FeedList"
|
import { FeedList } from "./components/FeedList"
|
||||||
import { LoginScreen } from "./components/LoginScreen"
|
import { LoginScreen } from "./components/LoginScreen"
|
||||||
import { CodeValidation } from "./components/CodeValidation"
|
import { CodeValidation } from "./components/CodeValidation"
|
||||||
import { OAuthPlaceholder } from "./components/OAuthPlaceholder"
|
import { OAuthPlaceholder } from "./components/OAuthPlaceholder"
|
||||||
import { SyncProfile } from "./components/SyncProfile"
|
import { SyncProfile } from "./components/SyncProfile"
|
||||||
|
import { SearchPage } from "./components/SearchPage"
|
||||||
|
import { DiscoverPage } from "./components/DiscoverPage"
|
||||||
import { useAuthStore } from "./stores/auth"
|
import { useAuthStore } from "./stores/auth"
|
||||||
|
import { useAppKeyboard } from "./hooks/useAppKeyboard"
|
||||||
import type { TabId } from "./components/Tab"
|
import type { TabId } from "./components/Tab"
|
||||||
import type { Feed, FeedVisibility } from "./types/feed"
|
|
||||||
import type { AuthScreen } from "./types/auth"
|
import type { AuthScreen } from "./types/auth"
|
||||||
|
|
||||||
// Mock data for demonstration
|
|
||||||
const MOCK_FEEDS: Feed[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
podcast: {
|
|
||||||
id: "p1",
|
|
||||||
title: "The Daily Tech News",
|
|
||||||
description: "Your daily dose of technology news and insights from around the world.",
|
|
||||||
feedUrl: "https://example.com/tech.rss",
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: [],
|
|
||||||
visibility: "public" as FeedVisibility,
|
|
||||||
sourceId: "rss",
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isPinned: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
podcast: {
|
|
||||||
id: "p2",
|
|
||||||
title: "Code & Coffee",
|
|
||||||
description: "Weekly discussions about programming, software development, and coffee.",
|
|
||||||
feedUrl: "https://example.com/code.rss",
|
|
||||||
lastUpdated: new Date(Date.now() - 86400000),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: [],
|
|
||||||
visibility: "private" as FeedVisibility,
|
|
||||||
sourceId: "rss",
|
|
||||||
lastUpdated: new Date(Date.now() - 86400000),
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
podcast: {
|
|
||||||
id: "p3",
|
|
||||||
title: "Science Explained",
|
|
||||||
description: "Breaking down complex scientific topics for curious minds.",
|
|
||||||
feedUrl: "https://example.com/science.rss",
|
|
||||||
lastUpdated: new Date(Date.now() - 172800000),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: [],
|
|
||||||
visibility: "public" as FeedVisibility,
|
|
||||||
sourceId: "itunes",
|
|
||||||
lastUpdated: new Date(Date.now() - 172800000),
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = createSignal<TabId>("discover")
|
const [activeTab, setActiveTab] = createSignal<TabId>("discover")
|
||||||
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login")
|
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login")
|
||||||
const [showAuthPanel, setShowAuthPanel] = createSignal(false)
|
const [showAuthPanel, setShowAuthPanel] = createSignal(false)
|
||||||
|
const [inputFocused, setInputFocused] = createSignal(false)
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
// Centralized keyboard handler for all tab navigation and shortcuts
|
||||||
|
useAppKeyboard({
|
||||||
|
get activeTab() { return activeTab() },
|
||||||
|
onTabChange: setActiveTab,
|
||||||
|
inputFocused: inputFocused(),
|
||||||
|
onAction: (action) => {
|
||||||
|
if (action === "escape") {
|
||||||
|
setShowAuthPanel(false)
|
||||||
|
setInputFocused(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const tab = activeTab()
|
const tab = activeTab()
|
||||||
|
|
||||||
@@ -79,7 +42,6 @@ export function App() {
|
|||||||
case "feeds":
|
case "feeds":
|
||||||
return (
|
return (
|
||||||
<FeedList
|
<FeedList
|
||||||
feeds={MOCK_FEEDS}
|
|
||||||
focused={true}
|
focused={true}
|
||||||
showEpisodeCount={true}
|
showEpisodeCount={true}
|
||||||
showLastUpdated={true}
|
showLastUpdated={true}
|
||||||
@@ -168,7 +130,22 @@ export function App() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case "discover":
|
case "discover":
|
||||||
|
return (
|
||||||
|
<DiscoverPage focused={!inputFocused()} />
|
||||||
|
)
|
||||||
|
|
||||||
case "search":
|
case "search":
|
||||||
|
return (
|
||||||
|
<SearchPage
|
||||||
|
focused={!inputFocused()}
|
||||||
|
onInputFocusChange={setInputFocused}
|
||||||
|
onSubscribe={(result) => {
|
||||||
|
// Would add to feeds
|
||||||
|
console.log("Subscribe to:", result.podcast.title)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
case "player":
|
case "player":
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
@@ -176,7 +153,7 @@ export function App() {
|
|||||||
<text>
|
<text>
|
||||||
<strong>{tab}</strong>
|
<strong>{tab}</strong>
|
||||||
<br />
|
<br />
|
||||||
<span fg="gray">Content placeholder - coming in later phases</span>
|
<span fg="gray">Player - coming in later phases</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
@@ -184,7 +161,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardHandler onTabSelect={setActiveTab}>
|
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
@@ -195,6 +171,5 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<box style={{ padding: 1 }}>{renderContent()}</box>
|
<box style={{ padding: 1 }}>{renderContent()}</box>
|
||||||
</Layout>
|
</Layout>
|
||||||
</KeyboardHandler>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/CategoryFilter.tsx
Normal file
42
src/components/CategoryFilter.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* CategoryFilter component - Horizontal category filter tabs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For } from "solid-js"
|
||||||
|
import type { DiscoverCategory } from "../stores/discover"
|
||||||
|
|
||||||
|
type CategoryFilterProps = {
|
||||||
|
categories: DiscoverCategory[]
|
||||||
|
selectedCategory: string
|
||||||
|
focused: boolean
|
||||||
|
onSelect?: (categoryId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryFilter(props: CategoryFilterProps) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={1} flexWrap="wrap">
|
||||||
|
<For each={props.categories}>
|
||||||
|
{(category) => {
|
||||||
|
const isSelected = () => props.selectedCategory === category.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
border={isSelected()}
|
||||||
|
backgroundColor={isSelected() ? "#444" : undefined}
|
||||||
|
onMouseDown={() => props.onSelect?.(category.id)}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span fg={isSelected() ? "cyan" : "gray"}>
|
||||||
|
{category.icon} {category.name}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
src/components/DiscoverPage.tsx
Normal file
193
src/components/DiscoverPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* DiscoverPage component - Main discover/browse interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "../stores/discover"
|
||||||
|
import { CategoryFilter } from "./CategoryFilter"
|
||||||
|
import { TrendingShows } from "./TrendingShows"
|
||||||
|
|
||||||
|
type DiscoverPageProps = {
|
||||||
|
focused: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusArea = "categories" | "shows"
|
||||||
|
|
||||||
|
export function DiscoverPage(props: DiscoverPageProps) {
|
||||||
|
const discoverStore = useDiscoverStore()
|
||||||
|
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows")
|
||||||
|
const [showIndex, setShowIndex] = createSignal(0)
|
||||||
|
const [categoryIndex, setCategoryIndex] = createSignal(0)
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useKeyboard((key) => {
|
||||||
|
if (!props.focused) return
|
||||||
|
|
||||||
|
const area = focusArea()
|
||||||
|
|
||||||
|
// Tab switches focus between categories and shows
|
||||||
|
if (key.name === "tab") {
|
||||||
|
if (key.shift) {
|
||||||
|
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||||
|
} else {
|
||||||
|
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category navigation
|
||||||
|
if (area === "categories") {
|
||||||
|
if (key.name === "left" || key.name === "h") {
|
||||||
|
setCategoryIndex((i) => Math.max(0, i - 1))
|
||||||
|
const cat = DISCOVER_CATEGORIES[categoryIndex()]
|
||||||
|
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||||
|
setShowIndex(0) // Reset show selection when changing category
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "right" || key.name === "l") {
|
||||||
|
setCategoryIndex((i) => Math.min(DISCOVER_CATEGORIES.length - 1, i + 1))
|
||||||
|
const cat = DISCOVER_CATEGORIES[categoryIndex()]
|
||||||
|
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||||
|
setShowIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
// Select category and move to shows
|
||||||
|
setFocusArea("shows")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setFocusArea("shows")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows navigation
|
||||||
|
if (area === "shows") {
|
||||||
|
const shows = discoverStore.filteredPodcasts()
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setShowIndex((i) => Math.min(i + 1, shows.length - 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
const newIndex = showIndex() - 1
|
||||||
|
if (newIndex < 0) {
|
||||||
|
setFocusArea("categories")
|
||||||
|
} else {
|
||||||
|
setShowIndex(newIndex)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
// Subscribe/unsubscribe
|
||||||
|
const podcast = shows[showIndex()]
|
||||||
|
if (podcast) {
|
||||||
|
discoverStore.toggleSubscription(podcast.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh with 'r'
|
||||||
|
if (key.name === "r") {
|
||||||
|
discoverStore.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCategorySelect = (categoryId: string) => {
|
||||||
|
discoverStore.setSelectedCategory(categoryId)
|
||||||
|
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId)
|
||||||
|
if (index >= 0) setCategoryIndex(index)
|
||||||
|
setShowIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowSelect = (index: number) => {
|
||||||
|
setShowIndex(index)
|
||||||
|
setFocusArea("shows")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubscribe = (podcast: { id: string }) => {
|
||||||
|
discoverStore.toggleSubscription(podcast.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%" gap={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<text>
|
||||||
|
<strong>Discover Podcasts</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">
|
||||||
|
{discoverStore.filteredPodcasts().length} shows
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<box onMouseDown={() => discoverStore.refresh()}>
|
||||||
|
<text>
|
||||||
|
<span fg="cyan">[R] Refresh</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<box border padding={1}>
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text>
|
||||||
|
<span fg={focusArea() === "categories" ? "cyan" : "gray"}>
|
||||||
|
Categories:
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<CategoryFilter
|
||||||
|
categories={discoverStore.categories}
|
||||||
|
selectedCategory={discoverStore.selectedCategory()}
|
||||||
|
focused={focusArea() === "categories"}
|
||||||
|
onSelect={handleCategorySelect}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Trending Shows */}
|
||||||
|
<box flexDirection="column" flexGrow={1} border>
|
||||||
|
<box padding={1} borderBottom>
|
||||||
|
<text>
|
||||||
|
<span fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
||||||
|
Trending in {
|
||||||
|
DISCOVER_CATEGORIES.find(
|
||||||
|
(c) => c.id === discoverStore.selectedCategory()
|
||||||
|
)?.name ?? "All"
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<TrendingShows
|
||||||
|
podcasts={discoverStore.filteredPodcasts()}
|
||||||
|
selectedIndex={showIndex()}
|
||||||
|
focused={focusArea() === "shows"}
|
||||||
|
isLoading={discoverStore.isLoading()}
|
||||||
|
onSelect={handleShowSelect}
|
||||||
|
onSubscribe={handleSubscribe}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Footer Hints */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[Tab] Switch focus</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[j/k] Navigate</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[Enter] Subscribe</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[R] Refresh</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
src/components/FeedDetail.tsx
Normal file
204
src/components/FeedDetail.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Feed detail view component for PodTUI
|
||||||
|
* Shows podcast info and episode list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For, Show } from "solid-js"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
interface FeedDetailProps {
|
||||||
|
feed: Feed
|
||||||
|
focused?: boolean
|
||||||
|
onBack?: () => void
|
||||||
|
onPlayEpisode?: (episode: Episode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedDetail(props: FeedDetailProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
|
const [showInfo, setShowInfo] = createSignal(true)
|
||||||
|
|
||||||
|
const episodes = () => {
|
||||||
|
// Sort episodes by publication date (newest first)
|
||||||
|
return [...props.feed.episodes].sort(
|
||||||
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${hrs}h ${mins % 60}m`
|
||||||
|
}
|
||||||
|
return `${mins}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return format(date, "MMM d, yyyy")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string }) => {
|
||||||
|
const eps = episodes()
|
||||||
|
|
||||||
|
if (key.name === "escape" && props.onBack) {
|
||||||
|
props.onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "i") {
|
||||||
|
setShowInfo((v) => !v)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||||
|
} else if (key.name === "down" || key.name === "j") {
|
||||||
|
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1))
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
const episode = eps[selectedIndex()]
|
||||||
|
if (episode && props.onPlayEpisode) {
|
||||||
|
props.onPlayEpisode(episode)
|
||||||
|
}
|
||||||
|
} else if (key.name === "home" || key.name === "g") {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
} else if (key.name === "end") {
|
||||||
|
setSelectedIndex(eps.length - 1)
|
||||||
|
} else if (key.name === "pageup") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 10))
|
||||||
|
} else if (key.name === "pagedown") {
|
||||||
|
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||||
|
>
|
||||||
|
{/* Header with back button */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onBack}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span fg="cyan">[Esc] Back</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={() => setShowInfo((v) => !v)}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Podcast info section */}
|
||||||
|
<Show when={showInfo()}>
|
||||||
|
<box border padding={1} flexDirection="column" gap={0}>
|
||||||
|
<text>
|
||||||
|
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||||
|
</text>
|
||||||
|
{props.feed.podcast.author && (
|
||||||
|
<text>
|
||||||
|
<span fg="gray">by </span>
|
||||||
|
<span fg="cyan">{props.feed.podcast.author}</span>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<box height={1} />
|
||||||
|
<text>
|
||||||
|
<span fg="gray">
|
||||||
|
{props.feed.podcast.description?.slice(0, 200)}
|
||||||
|
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">Episodes: </span>
|
||||||
|
<span fg="white">{props.feed.episodes.length}</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">Updated: </span>
|
||||||
|
<span fg="white">{formatDate(props.feed.lastUpdated)}</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
||||||
|
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
{props.feed.isPinned && (
|
||||||
|
<text>
|
||||||
|
<span fg="yellow">[Pinned]</span>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Episodes header */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text>
|
||||||
|
<strong>Episodes</strong>
|
||||||
|
<span fg="gray"> ({episodes().length} total)</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Episode list */}
|
||||||
|
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
||||||
|
<For each={episodes()}>
|
||||||
|
{(episode, index) => (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSelectedIndex(index())
|
||||||
|
if (props.onPlayEpisode) {
|
||||||
|
props.onPlayEpisode(episode)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text>
|
||||||
|
<span fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||||
|
{index() === selectedIndex() ? ">" : " "}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg={index() === selectedIndex() ? "white" : undefined}>
|
||||||
|
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
||||||
|
{episode.title}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">{formatDate(episode.pubDate)}</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">{formatDuration(episode.duration)}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<text>
|
||||||
|
<span fg="gray">
|
||||||
|
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Feed list component for PodTUI
|
* Feed list component for PodTUI
|
||||||
* Scrollable list of feeds with keyboard navigation
|
* Scrollable list of feeds with keyboard navigation and mouse support
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
interface FeedListProps {
|
interface FeedListProps {
|
||||||
feeds: Feed[]
|
|
||||||
focused?: boolean
|
focused?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
showEpisodeCount?: boolean
|
showEpisodeCount?: boolean
|
||||||
@@ -19,73 +18,10 @@ interface FeedListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedList(props: FeedListProps) {
|
export function FeedList(props: FeedListProps) {
|
||||||
|
const feedStore = useFeedStore()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
|
||||||
visibility: "all",
|
|
||||||
sortBy: "updated" as FeedSortField,
|
|
||||||
sortDirection: "desc",
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
const filteredFeeds = () => feedStore.getFilteredFeeds()
|
||||||
const filteredFeeds = (): Feed[] => {
|
|
||||||
let result = [...props.feeds]
|
|
||||||
|
|
||||||
// Filter by visibility
|
|
||||||
const vis = filter().visibility
|
|
||||||
if (vis && vis !== "all") {
|
|
||||||
result = result.filter((f) => f.visibility === vis)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by pinned only
|
|
||||||
if (filter().pinnedOnly) {
|
|
||||||
result = result.filter((f) => f.isPinned)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search query
|
|
||||||
const query = filter().searchQuery?.toLowerCase()
|
|
||||||
if (query) {
|
|
||||||
result = result.filter(
|
|
||||||
(f) =>
|
|
||||||
f.podcast.title.toLowerCase().includes(query) ||
|
|
||||||
f.customName?.toLowerCase().includes(query) ||
|
|
||||||
f.podcast.description?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort feeds
|
|
||||||
const sortField = filter().sortBy
|
|
||||||
const sortDir = filter().sortDirection === "asc" ? 1 : -1
|
|
||||||
|
|
||||||
result.sort((a, b) => {
|
|
||||||
switch (sortField) {
|
|
||||||
case "title":
|
|
||||||
return (
|
|
||||||
sortDir *
|
|
||||||
(a.customName || a.podcast.title).localeCompare(
|
|
||||||
b.customName || b.podcast.title
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case "episodeCount":
|
|
||||||
return sortDir * (a.episodes.length - b.episodes.length)
|
|
||||||
case "latestEpisode":
|
|
||||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0
|
|
||||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0
|
|
||||||
return sortDir * (aLatest - bLatest)
|
|
||||||
case "updated":
|
|
||||||
default:
|
|
||||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pinned feeds always first
|
|
||||||
result.sort((a, b) => {
|
|
||||||
if (a.isPinned && !b.isPinned) return -1
|
|
||||||
if (!a.isPinned && b.isPinned) return 1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string }) => {
|
const handleKeyPress = (key: { name: string }) => {
|
||||||
const feeds = filteredFeeds()
|
const feeds = filteredFeeds()
|
||||||
@@ -99,7 +35,7 @@ export function FeedList(props: FeedListProps) {
|
|||||||
if (feed && props.onOpenFeed) {
|
if (feed && props.onOpenFeed) {
|
||||||
props.onOpenFeed(feed)
|
props.onOpenFeed(feed)
|
||||||
}
|
}
|
||||||
} else if (key.name === "home") {
|
} else if (key.name === "home" || key.name === "g") {
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
} else if (key.name === "end") {
|
} else if (key.name === "end") {
|
||||||
setSelectedIndex(feeds.length - 1)
|
setSelectedIndex(feeds.length - 1)
|
||||||
@@ -107,6 +43,18 @@ export function FeedList(props: FeedListProps) {
|
|||||||
setSelectedIndex((i) => Math.max(0, i - 5))
|
setSelectedIndex((i) => Math.max(0, i - 5))
|
||||||
} else if (key.name === "pagedown") {
|
} else if (key.name === "pagedown") {
|
||||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5))
|
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5))
|
||||||
|
} else if (key.name === "p") {
|
||||||
|
// Toggle pin on selected feed
|
||||||
|
const feed = feeds[selectedIndex()]
|
||||||
|
if (feed) {
|
||||||
|
feedStore.togglePinned(feed.id)
|
||||||
|
}
|
||||||
|
} else if (key.name === "f") {
|
||||||
|
// Cycle visibility filter
|
||||||
|
cycleVisibilityFilter()
|
||||||
|
} else if (key.name === "s") {
|
||||||
|
// Cycle sort
|
||||||
|
cycleSortField()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify selection change
|
// Notify selection change
|
||||||
@@ -116,40 +64,82 @@ export function FeedList(props: FeedListProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleVisibilityFilter = () => {
|
const cycleVisibilityFilter = () => {
|
||||||
setFilter((f) => {
|
const current = feedStore.filter().visibility
|
||||||
const current = f.visibility
|
|
||||||
let next: FeedVisibility | "all"
|
let next: FeedVisibility | "all"
|
||||||
if (current === "all") next = "public"
|
if (current === "all") next = "public"
|
||||||
else if (current === "public") next = "private"
|
else if (current === "public") next = "private"
|
||||||
else next = "all"
|
else next = "all"
|
||||||
return { ...f, visibility: next }
|
feedStore.setFilter({ ...feedStore.filter(), visibility: next })
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const cycleSortField = () => {
|
||||||
|
const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"]
|
||||||
|
const current = feedStore.filter().sortBy as FeedSortField
|
||||||
|
const idx = sortOptions.indexOf(current)
|
||||||
|
const next = sortOptions[(idx + 1) % sortOptions.length]
|
||||||
|
feedStore.setFilter({ ...feedStore.filter(), sortBy: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibilityLabel = () => {
|
const visibilityLabel = () => {
|
||||||
const vis = filter().visibility
|
const vis = feedStore.filter().visibility
|
||||||
if (vis === "all") return "All"
|
if (vis === "all") return "All"
|
||||||
if (vis === "public") return "Public"
|
if (vis === "public") return "Public"
|
||||||
return "Private"
|
return "Private"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortLabel = () => {
|
||||||
|
const sort = feedStore.filter().sortBy
|
||||||
|
switch (sort) {
|
||||||
|
case "title": return "Title"
|
||||||
|
case "episodeCount": return "Episodes"
|
||||||
|
case "latestEpisode": return "Latest"
|
||||||
|
default: return "Updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFeedClick = (feed: Feed, index: number) => {
|
||||||
|
setSelectedIndex(index)
|
||||||
|
if (props.onSelectFeed) {
|
||||||
|
props.onSelectFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFeedDoubleClick = (feed: Feed) => {
|
||||||
|
if (props.onOpenFeed) {
|
||||||
|
props.onOpenFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={1}
|
gap={1}
|
||||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||||
>
|
>
|
||||||
{/* Header with filter */}
|
{/* Header with filter controls */}
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
<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>
|
<span fg="gray"> ({filteredFeeds().length} feeds)</span>
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="row" gap={2}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box border padding={0} onMouseDown={toggleVisibilityFilter}>
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={cycleVisibilityFilter}
|
||||||
|
>
|
||||||
<text>
|
<text>
|
||||||
<span fg="cyan">[F] {visibilityLabel()}</span>
|
<span fg="cyan">[f] {visibilityLabel()}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={cycleSortField}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span fg="cyan">[s] {sortLabel()}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
@@ -175,6 +165,10 @@ export function FeedList(props: FeedListProps) {
|
|||||||
>
|
>
|
||||||
<For each={filteredFeeds()}>
|
<For each={filteredFeeds()}>
|
||||||
{(feed, index) => (
|
{(feed, index) => (
|
||||||
|
<box
|
||||||
|
onMouseDown={() => handleFeedClick(feed, index())}
|
||||||
|
onDoubleClick={() => handleFeedDoubleClick(feed)}
|
||||||
|
>
|
||||||
<FeedItem
|
<FeedItem
|
||||||
feed={feed}
|
feed={feed}
|
||||||
isSelected={index() === selectedIndex()}
|
isSelected={index() === selectedIndex()}
|
||||||
@@ -182,16 +176,17 @@ export function FeedList(props: FeedListProps) {
|
|||||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||||
showLastUpdated={props.showLastUpdated ?? true}
|
showLastUpdated={props.showLastUpdated ?? true}
|
||||||
/>
|
/>
|
||||||
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Navigation help */}
|
{/* Navigation help */}
|
||||||
<box paddingTop={1}>
|
<box paddingTop={0}>
|
||||||
<text>
|
<text>
|
||||||
<span fg="gray">
|
<span fg="gray">
|
||||||
j/k or arrows to navigate, Enter to open, F to filter
|
j/k navigate | Enter open | p pin | f filter | s sort | Click to select
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"
|
|
||||||
import type { TabId } from "./Tab"
|
import type { TabId } from "./Tab"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use useAppKeyboard hook directly instead.
|
||||||
|
* This component is kept for backwards compatibility.
|
||||||
|
*/
|
||||||
type KeyboardHandlerProps = {
|
type KeyboardHandlerProps = {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
onTabSelect: (tab: TabId) => void
|
onTabSelect?: (tab: TabId) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyboardHandler(props: KeyboardHandlerProps) {
|
export function KeyboardHandler(props: KeyboardHandlerProps) {
|
||||||
useKeyboardShortcuts({
|
// Keyboard handling has been moved to useAppKeyboard hook
|
||||||
onTabNext: () => {
|
// This component is now just a passthrough
|
||||||
props.onTabSelect("discover")
|
|
||||||
},
|
|
||||||
onTabPrev: () => {
|
|
||||||
props.onTabSelect("settings")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{props.children}</>
|
return <>{props.children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/components/PodcastCard.tsx
Normal file
86
src/components/PodcastCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* PodcastCard component - Reusable card for displaying podcast info
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
|
||||||
|
type PodcastCardProps = {
|
||||||
|
podcast: Podcast
|
||||||
|
selected: boolean
|
||||||
|
compact?: boolean
|
||||||
|
onSelect?: () => void
|
||||||
|
onSubscribe?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PodcastCard(props: PodcastCardProps) {
|
||||||
|
const handleSubscribeClick = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation?.()
|
||||||
|
props.onSubscribe?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={props.selected ? "#333" : undefined}
|
||||||
|
onMouseDown={props.onSelect}
|
||||||
|
>
|
||||||
|
{/* Title Row */}
|
||||||
|
<box flexDirection="row" gap={2} alignItems="center">
|
||||||
|
<text>
|
||||||
|
<span fg={props.selected ? "cyan" : "white"}>
|
||||||
|
<strong>{props.podcast.title}</strong>
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<Show when={props.podcast.isSubscribed}>
|
||||||
|
<text>
|
||||||
|
<span fg="green">[+]</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Author */}
|
||||||
|
<Show when={props.podcast.author && !props.compact}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">by {props.podcast.author}</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Show when={props.podcast.description && !props.compact}>
|
||||||
|
<text>
|
||||||
|
<span fg={props.selected ? "white" : "gray"}>
|
||||||
|
{props.podcast.description!.length > 80
|
||||||
|
? props.podcast.description!.slice(0, 80) + "..."
|
||||||
|
: props.podcast.description}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Categories and Subscribe Button */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Show when={props.podcast.categories && props.podcast.categories.length > 0}>
|
||||||
|
{props.podcast.categories!.slice(0, 2).map((cat) => (
|
||||||
|
<text>
|
||||||
|
<span fg="yellow">[{cat}]</span>
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Show when={props.selected}>
|
||||||
|
<box onMouseDown={handleSubscribeClick}>
|
||||||
|
<text>
|
||||||
|
<span fg={props.podcast.isSubscribed ? "red" : "green"}>
|
||||||
|
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/SearchHistory.tsx
Normal file
94
src/components/SearchHistory.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* SearchHistory component for displaying and managing search history
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
|
||||||
|
type SearchHistoryProps = {
|
||||||
|
history: string[]
|
||||||
|
focused: boolean
|
||||||
|
selectedIndex: number
|
||||||
|
onSelect?: (query: string) => void
|
||||||
|
onRemove?: (query: string) => void
|
||||||
|
onClear?: () => void
|
||||||
|
onChange?: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchHistory(props: SearchHistoryProps) {
|
||||||
|
const handleSearchClick = (index: number, query: string) => {
|
||||||
|
props.onChange?.(index)
|
||||||
|
props.onSelect?.(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveClick = (e: MouseEvent, query: string) => {
|
||||||
|
e.stopPropagation?.()
|
||||||
|
props.onRemove?.(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text>
|
||||||
|
<span fg="gray">Recent Searches</span>
|
||||||
|
</text>
|
||||||
|
<Show when={props.history.length > 0}>
|
||||||
|
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
||||||
|
<text>
|
||||||
|
<span fg="red">[Clear All]</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={props.history.length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">No recent searches</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox height={10} showScrollIndicator>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={props.history}>
|
||||||
|
{(query, index) => {
|
||||||
|
const isSelected = () => index() === props.selectedIndex && props.focused
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={isSelected() ? "#333" : undefined}
|
||||||
|
onMouseDown={() => handleSearchClick(index(), query)}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">{">"}</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg={isSelected() ? "cyan" : "white"}>{query}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
onMouseDown={(e: MouseEvent) => handleRemoveClick(e, query)}
|
||||||
|
padding={0}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span fg="red">[x]</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
284
src/components/SearchPage.tsx
Normal file
284
src/components/SearchPage.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* SearchPage component - Main search interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, Show } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { useSearchStore } from "../stores/search"
|
||||||
|
import { SearchResults } from "./SearchResults"
|
||||||
|
import { SearchHistory } from "./SearchHistory"
|
||||||
|
import type { SearchResult } from "../types/source"
|
||||||
|
|
||||||
|
type SearchPageProps = {
|
||||||
|
focused: boolean
|
||||||
|
onSubscribe?: (result: SearchResult) => void
|
||||||
|
onInputFocusChange?: (focused: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusArea = "input" | "results" | "history"
|
||||||
|
|
||||||
|
export function SearchPage(props: SearchPageProps) {
|
||||||
|
const searchStore = useSearchStore()
|
||||||
|
const [focusArea, setFocusArea] = createSignal<FocusArea>("input")
|
||||||
|
const [inputValue, setInputValue] = createSignal("")
|
||||||
|
const [resultIndex, setResultIndex] = createSignal(0)
|
||||||
|
const [historyIndex, setHistoryIndex] = createSignal(0)
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const query = inputValue().trim()
|
||||||
|
if (query) {
|
||||||
|
await searchStore.search(query)
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
setResultIndex(0)
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHistorySelect = async (query: string) => {
|
||||||
|
setInputValue(query)
|
||||||
|
await searchStore.search(query)
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
setResultIndex(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResultSelect = (result: SearchResult) => {
|
||||||
|
props.onSubscribe?.(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useKeyboard((key) => {
|
||||||
|
if (!props.focused) return
|
||||||
|
|
||||||
|
const area = focusArea()
|
||||||
|
|
||||||
|
// Enter to search from input
|
||||||
|
if (key.name === "enter" && area === "input") {
|
||||||
|
handleSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab to cycle focus areas
|
||||||
|
if (key.name === "tab" && !key.shift) {
|
||||||
|
if (area === "input") {
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
} else if (searchStore.history().length > 0) {
|
||||||
|
setFocusArea("history")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
}
|
||||||
|
} else if (area === "results") {
|
||||||
|
if (searchStore.history().length > 0) {
|
||||||
|
setFocusArea("history")
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "tab" && key.shift) {
|
||||||
|
if (area === "input") {
|
||||||
|
if (searchStore.history().length > 0) {
|
||||||
|
setFocusArea("history")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
} else if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
}
|
||||||
|
} else if (area === "history") {
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up/Down for results and history
|
||||||
|
if (area === "results") {
|
||||||
|
const results = searchStore.results()
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setResultIndex((i) => Math.min(i + 1, results.length - 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setResultIndex((i) => Math.max(i - 1, 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
const result = results[resultIndex()]
|
||||||
|
if (result) handleResultSelect(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area === "history") {
|
||||||
|
const history = searchStore.history()
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setHistoryIndex((i) => Math.min(i + 1, history.length - 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setHistoryIndex((i) => Math.max(i - 1, 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
const query = history[historyIndex()]
|
||||||
|
if (query) handleHistorySelect(query)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape goes back to input
|
||||||
|
if (key.name === "escape") {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "/" focuses search input
|
||||||
|
if (key.name === "/" && area !== "input") {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%" gap={1}>
|
||||||
|
{/* Search Header */}
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>Search Podcasts</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text>
|
||||||
|
<span fg="gray">Search:</span>
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
value={inputValue()}
|
||||||
|
onInput={setInputValue}
|
||||||
|
placeholder="Enter podcast name, topic, or author..."
|
||||||
|
focused={props.focused && focusArea() === "input"}
|
||||||
|
width={50}
|
||||||
|
onFocus={() => props.onInputFocusChange?.(true)}
|
||||||
|
onBlur={() => props.onInputFocusChange?.(false)}
|
||||||
|
/>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
onMouseDown={handleSearch}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span fg="cyan">[Enter] Search</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Show when={searchStore.isSearching()}>
|
||||||
|
<text>
|
||||||
|
<span fg="yellow">Searching...</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={searchStore.error()}>
|
||||||
|
<text>
|
||||||
|
<span fg="red">{searchStore.error()}</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Main Content - Results or History */}
|
||||||
|
<box flexDirection="row" height="100%" gap={2}>
|
||||||
|
{/* Results Panel */}
|
||||||
|
<box flexDirection="column" flexGrow={1} border>
|
||||||
|
<box padding={1} borderBottom>
|
||||||
|
<text>
|
||||||
|
<span fg={focusArea() === "results" ? "cyan" : "gray"}>
|
||||||
|
Results ({searchStore.results().length})
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<Show
|
||||||
|
when={searchStore.results().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">
|
||||||
|
{searchStore.query()
|
||||||
|
? "No results found"
|
||||||
|
: "Enter a search term to find podcasts"}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchResults
|
||||||
|
results={searchStore.results()}
|
||||||
|
selectedIndex={resultIndex()}
|
||||||
|
focused={focusArea() === "results"}
|
||||||
|
onSelect={handleResultSelect}
|
||||||
|
onChange={setResultIndex}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* History Sidebar */}
|
||||||
|
<box width={30} border>
|
||||||
|
<box padding={1} flexDirection="column">
|
||||||
|
<box borderBottom paddingBottom={1}>
|
||||||
|
<text>
|
||||||
|
<span fg={focusArea() === "history" ? "cyan" : "gray"}>
|
||||||
|
History
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<SearchHistory
|
||||||
|
history={searchStore.history()}
|
||||||
|
selectedIndex={historyIndex()}
|
||||||
|
focused={focusArea() === "history"}
|
||||||
|
onSelect={handleHistorySelect}
|
||||||
|
onRemove={searchStore.removeFromHistory}
|
||||||
|
onClear={searchStore.clearHistory}
|
||||||
|
onChange={setHistoryIndex}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Footer Hints */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[Tab] Switch focus</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[/] Focus search</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[Enter] Select</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">[Esc] Back to search</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
src/components/SearchResults.tsx
Normal file
98
src/components/SearchResults.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* SearchResults component for displaying podcast search results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import type { SearchResult } from "../types/source"
|
||||||
|
|
||||||
|
type SearchResultsProps = {
|
||||||
|
results: SearchResult[]
|
||||||
|
selectedIndex: number
|
||||||
|
focused: boolean
|
||||||
|
onSelect?: (result: SearchResult) => void
|
||||||
|
onChange?: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResults(props: SearchResultsProps) {
|
||||||
|
const handleMouseDown = (index: number, result: SearchResult) => {
|
||||||
|
props.onChange?.(index)
|
||||||
|
props.onSelect?.(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={props.results.length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">No results found. Try a different search term.</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox height="100%" showScrollIndicator>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={props.results}>
|
||||||
|
{(result, index) => {
|
||||||
|
const isSelected = () => index() === props.selectedIndex
|
||||||
|
const podcast = result.podcast
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={isSelected() ? "#333" : undefined}
|
||||||
|
onMouseDown={() => handleMouseDown(index(), result)}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text>
|
||||||
|
<span fg={isSelected() ? "cyan" : "white"}>
|
||||||
|
<strong>{podcast.title}</strong>
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<Show when={podcast.isSubscribed}>
|
||||||
|
<text>
|
||||||
|
<span fg="green">[Subscribed]</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">({result.sourceId})</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Show when={podcast.author}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">by {podcast.author}</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={podcast.description}>
|
||||||
|
<text>
|
||||||
|
<span fg={isSelected() ? "white" : "gray"}>
|
||||||
|
{podcast.description!.length > 100
|
||||||
|
? podcast.description!.slice(0, 100) + "..."
|
||||||
|
: podcast.description}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={podcast.categories && podcast.categories.length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={podcast.categories!.slice(0, 3)}>
|
||||||
|
{(category) => (
|
||||||
|
<text>
|
||||||
|
<span fg="yellow">[{category}]</span>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
260
src/components/SourceManager.tsx
Normal file
260
src/components/SourceManager.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Source management component for PodTUI
|
||||||
|
* Add, remove, and configure podcast sources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For } from "solid-js"
|
||||||
|
import { useFeedStore } from "../stores/feed"
|
||||||
|
import type { PodcastSource, SourceType } from "../types/source"
|
||||||
|
|
||||||
|
interface SourceManagerProps {
|
||||||
|
focused?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusArea = "list" | "add" | "url"
|
||||||
|
|
||||||
|
export function SourceManager(props: SourceManagerProps) {
|
||||||
|
const feedStore = useFeedStore()
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
|
const [focusArea, setFocusArea] = createSignal<FocusArea>("list")
|
||||||
|
const [newSourceUrl, setNewSourceUrl] = createSignal("")
|
||||||
|
const [newSourceName, setNewSourceName] = createSignal("")
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const sources = () => feedStore.sources()
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "escape") {
|
||||||
|
if (focusArea() !== "list") {
|
||||||
|
setFocusArea("list")
|
||||||
|
setError(null)
|
||||||
|
} else if (props.onClose) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const areas: FocusArea[] = ["list", "add", "url"]
|
||||||
|
const idx = areas.indexOf(focusArea())
|
||||||
|
const nextIdx = key.shift
|
||||||
|
? (idx - 1 + areas.length) % areas.length
|
||||||
|
: (idx + 1) % areas.length
|
||||||
|
setFocusArea(areas[nextIdx])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusArea() === "list") {
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||||
|
} else if (key.name === "down" || key.name === "j") {
|
||||||
|
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1))
|
||||||
|
} else if (key.name === "return" || key.name === "enter" || key.name === "space") {
|
||||||
|
const source = sources()[selectedIndex()]
|
||||||
|
if (source) {
|
||||||
|
feedStore.toggleSource(source.id)
|
||||||
|
}
|
||||||
|
} else if (key.name === "d" || key.name === "delete") {
|
||||||
|
const source = sources()[selectedIndex()]
|
||||||
|
if (source) {
|
||||||
|
const removed = feedStore.removeSource(source.id)
|
||||||
|
if (!removed) {
|
||||||
|
setError("Cannot remove default sources")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key.name === "a") {
|
||||||
|
setFocusArea("add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSource = () => {
|
||||||
|
const url = newSourceUrl().trim()
|
||||||
|
const name = newSourceName().trim() || `Custom Source`
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
setError("URL is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
setError("Invalid URL format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feedStore.addSource({
|
||||||
|
name,
|
||||||
|
type: "rss" as SourceType,
|
||||||
|
baseUrl: url,
|
||||||
|
enabled: true,
|
||||||
|
description: `Custom RSS feed: ${url}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
setNewSourceUrl("")
|
||||||
|
setNewSourceName("")
|
||||||
|
setFocusArea("list")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSourceIcon = (source: PodcastSource) => {
|
||||||
|
if (source.type === "api") return "[API]"
|
||||||
|
if (source.type === "rss") return "[RSS]"
|
||||||
|
return "[?]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
gap={1}
|
||||||
|
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text>
|
||||||
|
<strong>Podcast Sources</strong>
|
||||||
|
</text>
|
||||||
|
<box border padding={0} onMouseDown={props.onClose}>
|
||||||
|
<text>
|
||||||
|
<span fg="cyan">[Esc] Close</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<text>
|
||||||
|
<span fg="gray">
|
||||||
|
Manage where to search for podcasts
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Source list */}
|
||||||
|
<box border padding={1} flexDirection="column">
|
||||||
|
<text>
|
||||||
|
<span fg={focusArea() === "list" ? "cyan" : "gray"}>Sources:</span>
|
||||||
|
</text>
|
||||||
|
<scrollbox height={6}>
|
||||||
|
<For each={sources()}>
|
||||||
|
{(source, index) => (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={
|
||||||
|
focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? "#333"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSelectedIndex(index())
|
||||||
|
setFocusArea("list")
|
||||||
|
feedStore.toggleSource(source.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span
|
||||||
|
fg={
|
||||||
|
focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? "cyan"
|
||||||
|
: "gray"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? ">"
|
||||||
|
: " "}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg={source.enabled ? "green" : "red"}>
|
||||||
|
{source.enabled ? "[x]" : "[ ]"}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span fg="yellow">{getSourceIcon(source)}</span>
|
||||||
|
</text>
|
||||||
|
<text>
|
||||||
|
<span
|
||||||
|
fg={
|
||||||
|
focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? "white"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source.name}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">
|
||||||
|
Space/Enter to toggle, d to delete, a to add
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Add new source form */}
|
||||||
|
<box border padding={1} flexDirection="column" gap={1}>
|
||||||
|
<text>
|
||||||
|
<span fg={focusArea() === "add" || focusArea() === "url" ? "cyan" : "gray"}>
|
||||||
|
Add New Source:
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">Name:</span>
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
value={newSourceName()}
|
||||||
|
onInput={setNewSourceName}
|
||||||
|
placeholder="My Custom Feed"
|
||||||
|
focused={props.focused && focusArea() === "add"}
|
||||||
|
width={25}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">URL:</span>
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
value={newSourceUrl()}
|
||||||
|
onInput={(v) => {
|
||||||
|
setNewSourceUrl(v)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com/feed.rss"
|
||||||
|
focused={props.focused && focusArea() === "url"}
|
||||||
|
width={35}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
width={15}
|
||||||
|
onMouseDown={handleAddSource}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
<span fg="green">[+] Add Source</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error() && (
|
||||||
|
<text>
|
||||||
|
<span fg="red">{error()}</span>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<text>
|
||||||
|
<span fg="gray">Tab to switch sections, Esc to close</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"
|
|
||||||
import { Tab, type TabId } from "./Tab"
|
import { Tab, type TabId } from "./Tab"
|
||||||
|
|
||||||
type TabNavigationProps = {
|
type TabNavigationProps = {
|
||||||
@@ -7,23 +6,6 @@ type TabNavigationProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TabNavigation(props: TabNavigationProps) {
|
export function TabNavigation(props: TabNavigationProps) {
|
||||||
useKeyboardShortcuts({
|
|
||||||
onTabNext: () => {
|
|
||||||
if (props.activeTab === "discover") props.onTabSelect("feeds")
|
|
||||||
else if (props.activeTab === "feeds") props.onTabSelect("search")
|
|
||||||
else if (props.activeTab === "search") props.onTabSelect("player")
|
|
||||||
else if (props.activeTab === "player") props.onTabSelect("settings")
|
|
||||||
else props.onTabSelect("discover")
|
|
||||||
},
|
|
||||||
onTabPrev: () => {
|
|
||||||
if (props.activeTab === "discover") props.onTabSelect("settings")
|
|
||||||
else if (props.activeTab === "settings") props.onTabSelect("player")
|
|
||||||
else if (props.activeTab === "player") props.onTabSelect("search")
|
|
||||||
else if (props.activeTab === "search") props.onTabSelect("feeds")
|
|
||||||
else props.onTabSelect("discover")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<Tab tab={{ id: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} />
|
<Tab tab={{ id: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} />
|
||||||
|
|||||||
55
src/components/TrendingShows.tsx
Normal file
55
src/components/TrendingShows.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* TrendingShows component - Grid/list of trending podcasts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import { PodcastCard } from "./PodcastCard"
|
||||||
|
|
||||||
|
type TrendingShowsProps = {
|
||||||
|
podcasts: Podcast[]
|
||||||
|
selectedIndex: number
|
||||||
|
focused: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
onSelect?: (index: number) => void
|
||||||
|
onSubscribe?: (podcast: Podcast) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendingShows(props: TrendingShowsProps) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%">
|
||||||
|
<Show when={props.isLoading}>
|
||||||
|
<box padding={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="yellow">Loading trending shows...</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.isLoading && props.podcasts.length === 0}>
|
||||||
|
<box padding={2}>
|
||||||
|
<text>
|
||||||
|
<span fg="gray">No podcasts found in this category.</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.isLoading && props.podcasts.length > 0}>
|
||||||
|
<scrollbox height="100%" showScrollIndicator>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={props.podcasts}>
|
||||||
|
{(podcast, index) => (
|
||||||
|
<PodcastCard
|
||||||
|
podcast={podcast}
|
||||||
|
selected={index() === props.selectedIndex && props.focused}
|
||||||
|
onSelect={() => props.onSelect?.(index())}
|
||||||
|
onSubscribe={() => props.onSubscribe?.(podcast)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
src/hooks/useAppKeyboard.ts
Normal file
99
src/hooks/useAppKeyboard.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Centralized keyboard shortcuts hook for PodTUI
|
||||||
|
* Single handler to prevent conflicts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||||
|
import type { TabId } from "../components/Tab"
|
||||||
|
|
||||||
|
const TAB_ORDER: TabId[] = ["discover", "feeds", "search", "player", "settings"]
|
||||||
|
|
||||||
|
type ShortcutOptions = {
|
||||||
|
activeTab: TabId
|
||||||
|
onTabChange: (tab: TabId) => void
|
||||||
|
onAction?: (action: string) => void
|
||||||
|
inputFocused?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppKeyboard(options: ShortcutOptions) {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
|
||||||
|
const getNextTab = (current: TabId): TabId => {
|
||||||
|
const idx = TAB_ORDER.indexOf(current)
|
||||||
|
return TAB_ORDER[(idx + 1) % TAB_ORDER.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPrevTab = (current: TabId): TabId => {
|
||||||
|
const idx = TAB_ORDER.indexOf(current)
|
||||||
|
return TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboard((key) => {
|
||||||
|
// Always allow quit
|
||||||
|
if (key.ctrl && key.name === "q") {
|
||||||
|
renderer.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip global shortcuts if input is focused (let input handle keys)
|
||||||
|
if (options.inputFocused) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab navigation with left/right arrows OR [ and ]
|
||||||
|
if (key.name === "right" || key.name === "]") {
|
||||||
|
options.onTabChange(getNextTab(options.activeTab))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "left" || key.name === "[") {
|
||||||
|
options.onTabChange(getPrevTab(options.activeTab))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number keys for direct tab access (1-5)
|
||||||
|
if (key.name === "1") {
|
||||||
|
options.onTabChange("discover")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "2") {
|
||||||
|
options.onTabChange("feeds")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "3") {
|
||||||
|
options.onTabChange("search")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "4") {
|
||||||
|
options.onTabChange("player")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "5") {
|
||||||
|
options.onTabChange("settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab key cycles tabs (Shift+Tab goes backwards)
|
||||||
|
if (key.name === "tab") {
|
||||||
|
if (key.shift) {
|
||||||
|
options.onTabChange(getPrevTab(options.activeTab))
|
||||||
|
} else {
|
||||||
|
options.onTabChange(getNextTab(options.activeTab))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward other actions
|
||||||
|
if (options.onAction) {
|
||||||
|
if (key.ctrl && key.name === "s") {
|
||||||
|
options.onAction("save")
|
||||||
|
} else if (key.ctrl && key.name === "f") {
|
||||||
|
options.onAction("find")
|
||||||
|
} else if (key.name === "escape") {
|
||||||
|
options.onAction("escape")
|
||||||
|
} else if (key.name === "?" || (key.shift && key.name === "/")) {
|
||||||
|
options.onAction("help")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
215
src/stores/discover.ts
Normal file
215
src/stores/discover.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Discover store for PodTUI
|
||||||
|
* Manages trending/popular podcasts and category filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
|
||||||
|
export interface DiscoverCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||||
|
{ id: "all", name: "All", icon: "*" },
|
||||||
|
{ id: "technology", name: "Technology", icon: ">" },
|
||||||
|
{ id: "science", name: "Science", icon: "~" },
|
||||||
|
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||||
|
{ id: "news", name: "News", icon: "!" },
|
||||||
|
{ id: "business", name: "Business", icon: "$" },
|
||||||
|
{ id: "health", name: "Health", icon: "+" },
|
||||||
|
{ id: "education", name: "Education", icon: "?" },
|
||||||
|
{ id: "sports", name: "Sports", icon: "#" },
|
||||||
|
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||||
|
{ id: "arts", name: "Arts", icon: "@" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Mock trending podcasts */
|
||||||
|
const TRENDING_PODCASTS: Podcast[] = [
|
||||||
|
{
|
||||||
|
id: "trend-1",
|
||||||
|
title: "AI Today",
|
||||||
|
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
||||||
|
feedUrl: "https://example.com/aitoday.rss",
|
||||||
|
author: "Tech Futures",
|
||||||
|
categories: ["Technology", "Science"],
|
||||||
|
imageUrl: undefined,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-2",
|
||||||
|
title: "The History Hour",
|
||||||
|
description: "Fascinating stories from history that shaped our world today.",
|
||||||
|
feedUrl: "https://example.com/historyhour.rss",
|
||||||
|
author: "History Channel",
|
||||||
|
categories: ["Education", "History"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-3",
|
||||||
|
title: "Comedy Gold",
|
||||||
|
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
||||||
|
feedUrl: "https://example.com/comedygold.rss",
|
||||||
|
author: "Laugh Factory",
|
||||||
|
categories: ["Comedy", "Entertainment"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-4",
|
||||||
|
title: "Market Watch",
|
||||||
|
description: "Daily financial news, stock analysis, and investing tips.",
|
||||||
|
feedUrl: "https://example.com/marketwatch.rss",
|
||||||
|
author: "Finance Daily",
|
||||||
|
categories: ["Business", "News"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-5",
|
||||||
|
title: "Science Weekly",
|
||||||
|
description: "Breaking science news and in-depth analysis of the latest research.",
|
||||||
|
feedUrl: "https://example.com/scienceweekly.rss",
|
||||||
|
author: "Science Network",
|
||||||
|
categories: ["Science", "Education"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-6",
|
||||||
|
title: "True Crime Files",
|
||||||
|
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
||||||
|
feedUrl: "https://example.com/truecrime.rss",
|
||||||
|
author: "Crime Network",
|
||||||
|
categories: ["True Crime", "Documentary"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-7",
|
||||||
|
title: "Wellness Journey",
|
||||||
|
description: "Tips for mental and physical health, meditation, and mindful living.",
|
||||||
|
feedUrl: "https://example.com/wellness.rss",
|
||||||
|
author: "Health Media",
|
||||||
|
categories: ["Health", "Self-Help"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-8",
|
||||||
|
title: "Sports Talk Live",
|
||||||
|
description: "Live commentary, analysis, and interviews from the world of sports.",
|
||||||
|
feedUrl: "https://example.com/sportstalk.rss",
|
||||||
|
author: "Sports Network",
|
||||||
|
categories: ["Sports", "News"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-9",
|
||||||
|
title: "Creative Minds",
|
||||||
|
description: "Interviews with artists, designers, and creative professionals.",
|
||||||
|
feedUrl: "https://example.com/creativeminds.rss",
|
||||||
|
author: "Arts Weekly",
|
||||||
|
categories: ["Arts", "Culture"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-10",
|
||||||
|
title: "Dev Talk",
|
||||||
|
description: "Software development, programming tutorials, and tech career advice.",
|
||||||
|
feedUrl: "https://example.com/devtalk.rss",
|
||||||
|
author: "Code Academy",
|
||||||
|
categories: ["Technology", "Education"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Create discover store */
|
||||||
|
export function createDiscoverStore() {
|
||||||
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
|
||||||
|
|
||||||
|
/** Get filtered podcasts by category */
|
||||||
|
const filteredPodcasts = () => {
|
||||||
|
const category = selectedCategory()
|
||||||
|
if (category === "all") {
|
||||||
|
return podcasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
return podcasts().filter((p) => {
|
||||||
|
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
||||||
|
return cats.some((c) => c.includes(category.replace("-", " ")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a podcast */
|
||||||
|
const subscribe = (podcastId: string) => {
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe from a podcast */
|
||||||
|
const unsubscribe = (podcastId: string) => {
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle subscription */
|
||||||
|
const toggleSubscription = (podcastId: string) => {
|
||||||
|
const podcast = podcasts().find((p) => p.id === podcastId)
|
||||||
|
if (podcast?.isSubscribed) {
|
||||||
|
unsubscribe(podcastId)
|
||||||
|
} else {
|
||||||
|
subscribe(podcastId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh trending podcasts (mock) */
|
||||||
|
const refresh = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
// In real app, would fetch from API
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
selectedCategory,
|
||||||
|
isLoading,
|
||||||
|
podcasts,
|
||||||
|
filteredPodcasts,
|
||||||
|
categories: DISCOVER_CATEGORIES,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSelectedCategory,
|
||||||
|
subscribe,
|
||||||
|
unsubscribe,
|
||||||
|
toggleSubscription,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton discover store */
|
||||||
|
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
|
||||||
|
|
||||||
|
export function useDiscoverStore() {
|
||||||
|
if (!discoverStoreInstance) {
|
||||||
|
discoverStoreInstance = createDiscoverStore()
|
||||||
|
}
|
||||||
|
return discoverStoreInstance
|
||||||
|
}
|
||||||
422
src/stores/feed.ts
Normal file
422
src/stores/feed.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* Feed store for PodTUI
|
||||||
|
* Manages feed data, sources, and filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import type { Episode, EpisodeStatus } from "../types/episode"
|
||||||
|
import type { PodcastSource, SourceType } from "../types/source"
|
||||||
|
import { DEFAULT_SOURCES } from "../types/source"
|
||||||
|
|
||||||
|
/** Storage keys */
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
feeds: "podtui_feeds",
|
||||||
|
sources: "podtui_sources",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create initial mock feeds for demonstration */
|
||||||
|
function createMockFeeds(): Feed[] {
|
||||||
|
const now = new Date()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
podcast: {
|
||||||
|
id: "p1",
|
||||||
|
title: "The Daily Tech News",
|
||||||
|
description: "Your daily dose of technology news and insights from around the world. We cover the latest in AI, software, hardware, and digital culture.",
|
||||||
|
feedUrl: "https://example.com/tech.rss",
|
||||||
|
author: "Tech Media Inc",
|
||||||
|
categories: ["Technology", "News"],
|
||||||
|
lastUpdated: now,
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p1", 25),
|
||||||
|
visibility: "public" as FeedVisibility,
|
||||||
|
sourceId: "rss",
|
||||||
|
lastUpdated: now,
|
||||||
|
isPinned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
podcast: {
|
||||||
|
id: "p2",
|
||||||
|
title: "Code & Coffee",
|
||||||
|
description: "Weekly discussions about programming, software development, and the developer lifestyle. Best enjoyed with your morning coffee.",
|
||||||
|
feedUrl: "https://example.com/code.rss",
|
||||||
|
author: "Developer Collective",
|
||||||
|
categories: ["Technology", "Programming"],
|
||||||
|
lastUpdated: new Date(Date.now() - 86400000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p2", 50),
|
||||||
|
visibility: "private" as FeedVisibility,
|
||||||
|
sourceId: "rss",
|
||||||
|
lastUpdated: new Date(Date.now() - 86400000),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
podcast: {
|
||||||
|
id: "p3",
|
||||||
|
title: "Science Explained",
|
||||||
|
description: "Breaking down complex scientific topics for curious minds. From quantum physics to biology, we make science accessible.",
|
||||||
|
feedUrl: "https://example.com/science.rss",
|
||||||
|
author: "Science Network",
|
||||||
|
categories: ["Science", "Education"],
|
||||||
|
lastUpdated: new Date(Date.now() - 172800000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p3", 120),
|
||||||
|
visibility: "public" as FeedVisibility,
|
||||||
|
sourceId: "itunes",
|
||||||
|
lastUpdated: new Date(Date.now() - 172800000),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
podcast: {
|
||||||
|
id: "p4",
|
||||||
|
title: "History Uncovered",
|
||||||
|
description: "Deep dives into fascinating historical events and figures you never learned about in school.",
|
||||||
|
feedUrl: "https://example.com/history.rss",
|
||||||
|
author: "History Channel",
|
||||||
|
categories: ["History", "Education"],
|
||||||
|
lastUpdated: new Date(Date.now() - 259200000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p4", 80),
|
||||||
|
visibility: "public" as FeedVisibility,
|
||||||
|
sourceId: "rss",
|
||||||
|
lastUpdated: new Date(Date.now() - 259200000),
|
||||||
|
isPinned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
podcast: {
|
||||||
|
id: "p5",
|
||||||
|
title: "Startup Stories",
|
||||||
|
description: "Founders share their journey from idea to exit. Learn from their successes and failures.",
|
||||||
|
feedUrl: "https://example.com/startup.rss",
|
||||||
|
author: "Entrepreneur Media",
|
||||||
|
categories: ["Business", "Technology"],
|
||||||
|
lastUpdated: new Date(Date.now() - 345600000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p5", 45),
|
||||||
|
visibility: "private" as FeedVisibility,
|
||||||
|
sourceId: "itunes",
|
||||||
|
lastUpdated: new Date(Date.now() - 345600000),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create mock episodes for a podcast */
|
||||||
|
function createMockEpisodes(podcastId: string, count: number): Episode[] {
|
||||||
|
const episodes: Episode[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
episodes.push({
|
||||||
|
id: `${podcastId}-ep-${i + 1}`,
|
||||||
|
podcastId,
|
||||||
|
title: `Episode ${count - i}: Sample Episode Title`,
|
||||||
|
description: `This is the description for episode ${count - i}. It contains interesting content about various topics.`,
|
||||||
|
audioUrl: `https://example.com/audio/${podcastId}/${i + 1}.mp3`,
|
||||||
|
duration: 1800 + Math.random() * 3600, // 30-90 minutes
|
||||||
|
pubDate: new Date(Date.now() - i * 604800000), // Weekly episodes
|
||||||
|
episodeNumber: count - i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return episodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load feeds from localStorage */
|
||||||
|
function loadFeeds(): Feed[] {
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return createMockFeeds()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.feeds)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Convert date strings
|
||||||
|
return parsed.map((feed: Feed) => ({
|
||||||
|
...feed,
|
||||||
|
lastUpdated: new Date(feed.lastUpdated),
|
||||||
|
podcast: {
|
||||||
|
...feed.podcast,
|
||||||
|
lastUpdated: new Date(feed.podcast.lastUpdated),
|
||||||
|
},
|
||||||
|
episodes: feed.episodes.map((ep: Episode) => ({
|
||||||
|
...ep,
|
||||||
|
pubDate: new Date(ep.pubDate),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMockFeeds()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save feeds to localStorage */
|
||||||
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load sources from localStorage */
|
||||||
|
function loadSources(): PodcastSource[] {
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return [...DEFAULT_SOURCES]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.sources)
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...DEFAULT_SOURCES]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save sources to localStorage */
|
||||||
|
function saveSources(sources: PodcastSource[]): void {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.sources, JSON.stringify(sources))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create feed store */
|
||||||
|
export function createFeedStore() {
|
||||||
|
const [feeds, setFeeds] = createSignal<Feed[]>(loadFeeds())
|
||||||
|
const [sources, setSources] = createSignal<PodcastSource[]>(loadSources())
|
||||||
|
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||||
|
visibility: "all",
|
||||||
|
sortBy: "updated" as FeedSortField,
|
||||||
|
sortDirection: "desc",
|
||||||
|
})
|
||||||
|
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
/** Get filtered and sorted feeds */
|
||||||
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
|
let result = [...feeds()]
|
||||||
|
const f = filter()
|
||||||
|
|
||||||
|
// Filter by visibility
|
||||||
|
if (f.visibility && f.visibility !== "all") {
|
||||||
|
result = result.filter((feed) => feed.visibility === f.visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by source
|
||||||
|
if (f.sourceId) {
|
||||||
|
result = result.filter((feed) => feed.sourceId === f.sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by pinned
|
||||||
|
if (f.pinnedOnly) {
|
||||||
|
result = result.filter((feed) => feed.isPinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (f.searchQuery) {
|
||||||
|
const query = f.searchQuery.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
(feed) =>
|
||||||
|
feed.podcast.title.toLowerCase().includes(query) ||
|
||||||
|
feed.customName?.toLowerCase().includes(query) ||
|
||||||
|
feed.podcast.description?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by selected field
|
||||||
|
const sortDir = f.sortDirection === "asc" ? 1 : -1
|
||||||
|
result.sort((a, b) => {
|
||||||
|
switch (f.sortBy) {
|
||||||
|
case "title":
|
||||||
|
return sortDir * (a.customName || a.podcast.title).localeCompare(b.customName || b.podcast.title)
|
||||||
|
case "episodeCount":
|
||||||
|
return sortDir * (a.episodes.length - b.episodes.length)
|
||||||
|
case "latestEpisode":
|
||||||
|
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0
|
||||||
|
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0
|
||||||
|
return sortDir * (aLatest - bLatest)
|
||||||
|
case "updated":
|
||||||
|
default:
|
||||||
|
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pinned feeds always first
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.isPinned && !b.isPinned) return -1
|
||||||
|
if (!a.isPinned && b.isPinned) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get episodes in reverse chronological order across all feeds */
|
||||||
|
const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed }> => {
|
||||||
|
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []
|
||||||
|
|
||||||
|
for (const feed of feeds()) {
|
||||||
|
for (const episode of feed.episodes) {
|
||||||
|
allEpisodes.push({ episode, feed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by publication date (newest first)
|
||||||
|
allEpisodes.sort((a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime())
|
||||||
|
|
||||||
|
return allEpisodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a new feed */
|
||||||
|
const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = "public") => {
|
||||||
|
const newFeed: Feed = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
podcast,
|
||||||
|
episodes: [],
|
||||||
|
visibility,
|
||||||
|
sourceId,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isPinned: false,
|
||||||
|
}
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = [...prev, newFeed]
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
return newFeed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a feed */
|
||||||
|
const removeFeed = (feedId: string) => {
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.filter((f) => f.id !== feedId)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a feed */
|
||||||
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f
|
||||||
|
)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle feed pinned status */
|
||||||
|
const togglePinned = (feedId: string) => {
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f
|
||||||
|
)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a source */
|
||||||
|
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||||
|
const newSource: PodcastSource = {
|
||||||
|
...source,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
}
|
||||||
|
setSources((prev) => {
|
||||||
|
const updated = [...prev, newSource]
|
||||||
|
saveSources(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
return newSource
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a source */
|
||||||
|
const removeSource = (sourceId: string) => {
|
||||||
|
// Don't remove default sources
|
||||||
|
if (sourceId === "itunes" || sourceId === "rss") return false
|
||||||
|
|
||||||
|
setSources((prev) => {
|
||||||
|
const updated = prev.filter((s) => s.id !== sourceId)
|
||||||
|
saveSources(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle source enabled status */
|
||||||
|
const toggleSource = (sourceId: string) => {
|
||||||
|
setSources((prev) => {
|
||||||
|
const updated = prev.map((s) =>
|
||||||
|
s.id === sourceId ? { ...s, enabled: !s.enabled } : s
|
||||||
|
)
|
||||||
|
saveSources(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get feed by ID */
|
||||||
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
|
return feeds().find((f) => f.id === feedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get selected feed */
|
||||||
|
const getSelectedFeed = (): Feed | undefined => {
|
||||||
|
const id = selectedFeedId()
|
||||||
|
return id ? getFeed(id) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
feeds,
|
||||||
|
sources,
|
||||||
|
filter,
|
||||||
|
selectedFeedId,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
getFilteredFeeds,
|
||||||
|
getAllEpisodesChronological,
|
||||||
|
getFeed,
|
||||||
|
getSelectedFeed,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setFilter,
|
||||||
|
setSelectedFeedId,
|
||||||
|
addFeed,
|
||||||
|
removeFeed,
|
||||||
|
updateFeed,
|
||||||
|
togglePinned,
|
||||||
|
addSource,
|
||||||
|
removeSource,
|
||||||
|
toggleSource,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton feed store */
|
||||||
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null
|
||||||
|
|
||||||
|
export function useFeedStore() {
|
||||||
|
if (!feedStoreInstance) {
|
||||||
|
feedStoreInstance = createFeedStore()
|
||||||
|
}
|
||||||
|
return feedStoreInstance
|
||||||
|
}
|
||||||
239
src/stores/search.ts
Normal file
239
src/stores/search.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Search store for PodTUI
|
||||||
|
* Manages search state, history, and results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import type { PodcastSource, SearchResult } from "../types/source"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "podtui_search_history"
|
||||||
|
const MAX_HISTORY = 20
|
||||||
|
|
||||||
|
export interface SearchState {
|
||||||
|
query: string
|
||||||
|
isSearching: boolean
|
||||||
|
results: SearchResult[]
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock search results for demonstration */
|
||||||
|
const MOCK_PODCASTS: Podcast[] = [
|
||||||
|
{
|
||||||
|
id: "search-1",
|
||||||
|
title: "Tech Talk Daily",
|
||||||
|
description: "Daily technology news and analysis from Silicon Valley experts.",
|
||||||
|
feedUrl: "https://example.com/techtalk.rss",
|
||||||
|
author: "Tech Media Group",
|
||||||
|
categories: ["Technology", "News"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search-2",
|
||||||
|
title: "The Science Hour",
|
||||||
|
description: "Weekly deep dives into the latest scientific discoveries and research.",
|
||||||
|
feedUrl: "https://example.com/sciencehour.rss",
|
||||||
|
author: "Science Network",
|
||||||
|
categories: ["Science", "Education"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search-3",
|
||||||
|
title: "History Lessons",
|
||||||
|
description: "Fascinating stories from history that shaped our world.",
|
||||||
|
feedUrl: "https://example.com/historylessons.rss",
|
||||||
|
author: "History Channel",
|
||||||
|
categories: ["History", "Education"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search-4",
|
||||||
|
title: "Business Insights",
|
||||||
|
description: "Expert analysis on business trends, markets, and entrepreneurship.",
|
||||||
|
feedUrl: "https://example.com/businessinsights.rss",
|
||||||
|
author: "Business Weekly",
|
||||||
|
categories: ["Business", "Finance"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search-5",
|
||||||
|
title: "True Crime Stories",
|
||||||
|
description: "In-depth investigations into real criminal cases and mysteries.",
|
||||||
|
feedUrl: "https://example.com/truecrime.rss",
|
||||||
|
author: "Crime Network",
|
||||||
|
categories: ["True Crime", "Documentary"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search-6",
|
||||||
|
title: "Comedy Hour",
|
||||||
|
description: "Stand-up comedy, sketches, and hilarious conversations.",
|
||||||
|
feedUrl: "https://example.com/comedyhour.rss",
|
||||||
|
author: "Laugh Factory",
|
||||||
|
categories: ["Comedy", "Entertainment"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search-7",
|
||||||
|
title: "Mindful Living",
|
||||||
|
description: "Meditation, wellness, and mental health tips for a better life.",
|
||||||
|
feedUrl: "https://example.com/mindful.rss",
|
||||||
|
author: "Wellness Media",
|
||||||
|
categories: ["Health", "Self-Help"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search-8",
|
||||||
|
title: "Sports Central",
|
||||||
|
description: "Coverage of all major sports, analysis, and athlete interviews.",
|
||||||
|
feedUrl: "https://example.com/sportscentral.rss",
|
||||||
|
author: "Sports Network",
|
||||||
|
categories: ["Sports", "News"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Load search history from localStorage */
|
||||||
|
function loadHistory(): string[] {
|
||||||
|
if (typeof localStorage === "undefined") return []
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return stored ? JSON.parse(stored) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save search history to localStorage */
|
||||||
|
function saveHistory(history: string[]): void {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create search store */
|
||||||
|
export function createSearchStore() {
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const [isSearching, setIsSearching] = createSignal(false)
|
||||||
|
const [results, setResults] = createSignal<SearchResult[]>([])
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [history, setHistory] = createSignal<string[]>(loadHistory())
|
||||||
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
|
||||||
|
|
||||||
|
/** Perform search (mock implementation) */
|
||||||
|
const search = async (searchQuery: string): Promise<void> => {
|
||||||
|
const q = searchQuery.trim()
|
||||||
|
if (!q) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuery(q)
|
||||||
|
setIsSearching(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
addToHistory(q)
|
||||||
|
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((r) => setTimeout(r, 300 + Math.random() * 500))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mock search - filter by query
|
||||||
|
const queryLower = q.toLowerCase()
|
||||||
|
const matchingPodcasts = MOCK_PODCASTS.filter(
|
||||||
|
(p) =>
|
||||||
|
p.title.toLowerCase().includes(queryLower) ||
|
||||||
|
p.description.toLowerCase().includes(queryLower) ||
|
||||||
|
p.categories?.some((c) => c.toLowerCase().includes(queryLower)) ||
|
||||||
|
p.author?.toLowerCase().includes(queryLower)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert to search results
|
||||||
|
const searchResults: SearchResult[] = matchingPodcasts.map((podcast, i) => ({
|
||||||
|
sourceId: i % 2 === 0 ? "itunes" : "rss",
|
||||||
|
podcast,
|
||||||
|
score: 1 - i * 0.1, // Mock relevance score
|
||||||
|
}))
|
||||||
|
|
||||||
|
setResults(searchResults)
|
||||||
|
} catch (e) {
|
||||||
|
setError("Search failed. Please try again.")
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add query to history */
|
||||||
|
const addToHistory = (q: string) => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
// Remove duplicates and add to front
|
||||||
|
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase())
|
||||||
|
const updated = [q, ...filtered].slice(0, MAX_HISTORY)
|
||||||
|
saveHistory(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear search history */
|
||||||
|
const clearHistory = () => {
|
||||||
|
setHistory([])
|
||||||
|
saveHistory([])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove single history item */
|
||||||
|
const removeFromHistory = (q: string) => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
const updated = prev.filter((h) => h !== q)
|
||||||
|
saveHistory(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear results */
|
||||||
|
const clearResults = () => {
|
||||||
|
setResults([])
|
||||||
|
setQuery("")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
query,
|
||||||
|
isSearching,
|
||||||
|
results,
|
||||||
|
error,
|
||||||
|
history,
|
||||||
|
selectedSources,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
search,
|
||||||
|
setQuery,
|
||||||
|
clearResults,
|
||||||
|
clearHistory,
|
||||||
|
removeFromHistory,
|
||||||
|
setSelectedSources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton search store */
|
||||||
|
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null
|
||||||
|
|
||||||
|
export function useSearchStore() {
|
||||||
|
if (!searchStoreInstance) {
|
||||||
|
searchStoreInstance = createSearchStore()
|
||||||
|
}
|
||||||
|
return searchStoreInstance
|
||||||
|
}
|
||||||
@@ -63,9 +63,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
- [x] 05 — Create feed data models and types → `05-feed-management.md`
|
- [x] 05 — Create feed data models and types → `05-feed-management.md`
|
||||||
- [x] 28 — Create feed data models and types → `28-feed-types.md`
|
- [x] 28 — Create feed data models and types → `28-feed-types.md`
|
||||||
- [x] 29 — Build feed list component (public/private feeds) → `29-feed-list.md`
|
- [x] 29 — Build feed list component (public/private feeds) → `29-feed-list.md`
|
||||||
- [ ] 30 — Implement feed source management (add/remove sources) → `30-source-management.md`
|
- [x] 30 — Implement feed source management (add/remove sources) → `30-source-management.md`
|
||||||
- [ ] 31 — Add reverse chronological ordering → `31-reverse-chronological.md`
|
- [x] 31 — Add reverse chronological ordering → `31-reverse-chronological.md`
|
||||||
- [ ] 32 — Create feed detail view → `32-feed-detail.md`
|
- [x] 32 — Create feed detail view → `32-feed-detail.md`
|
||||||
|
|
||||||
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user