From bd4747679d10e99305b4cdb6cdf977409fd291b4 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 4 Feb 2026 01:18:59 -0500 Subject: [PATCH] fix keyboard, finish 05 --- src/App.tsx | 111 +++----- src/components/CategoryFilter.tsx | 42 +++ src/components/DiscoverPage.tsx | 193 +++++++++++++ src/components/FeedDetail.tsx | 204 ++++++++++++++ src/components/FeedList.tsx | 181 ++++++------- src/components/KeyboardHandler.tsx | 18 +- src/components/PodcastCard.tsx | 86 ++++++ src/components/SearchHistory.tsx | 94 +++++++ src/components/SearchPage.tsx | 284 +++++++++++++++++++ src/components/SearchResults.tsx | 98 +++++++ src/components/SourceManager.tsx | 260 ++++++++++++++++++ src/components/TabNavigation.tsx | 18 -- src/components/TrendingShows.tsx | 55 ++++ src/hooks/useAppKeyboard.ts | 99 +++++++ src/stores/discover.ts | 215 +++++++++++++++ src/stores/feed.ts | 422 +++++++++++++++++++++++++++++ src/stores/search.ts | 239 ++++++++++++++++ tasks/podcast-tui-app/README.md | 6 +- 18 files changed, 2432 insertions(+), 193 deletions(-) create mode 100644 src/components/CategoryFilter.tsx create mode 100644 src/components/DiscoverPage.tsx create mode 100644 src/components/FeedDetail.tsx create mode 100644 src/components/PodcastCard.tsx create mode 100644 src/components/SearchHistory.tsx create mode 100644 src/components/SearchPage.tsx create mode 100644 src/components/SearchResults.tsx create mode 100644 src/components/SourceManager.tsx create mode 100644 src/components/TrendingShows.tsx create mode 100644 src/hooks/useAppKeyboard.ts create mode 100644 src/stores/discover.ts create mode 100644 src/stores/feed.ts create mode 100644 src/stores/search.ts diff --git a/src/App.tsx b/src/App.tsx index e9080b0..0536eea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,76 +2,39 @@ import { createSignal } from "solid-js" import { Layout } from "./components/Layout" import { Navigation } from "./components/Navigation" import { TabNavigation } from "./components/TabNavigation" -import { KeyboardHandler } from "./components/KeyboardHandler" import { SyncPanel } from "./components/SyncPanel" import { FeedList } from "./components/FeedList" import { LoginScreen } from "./components/LoginScreen" import { CodeValidation } from "./components/CodeValidation" import { OAuthPlaceholder } from "./components/OAuthPlaceholder" import { SyncProfile } from "./components/SyncProfile" +import { SearchPage } from "./components/SearchPage" +import { DiscoverPage } from "./components/DiscoverPage" import { useAuthStore } from "./stores/auth" +import { useAppKeyboard } from "./hooks/useAppKeyboard" import type { TabId } from "./components/Tab" -import type { Feed, FeedVisibility } from "./types/feed" 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() { const [activeTab, setActiveTab] = createSignal("discover") const [authScreen, setAuthScreen] = createSignal("login") const [showAuthPanel, setShowAuthPanel] = createSignal(false) + const [inputFocused, setInputFocused] = createSignal(false) 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 tab = activeTab() @@ -79,7 +42,6 @@ export function App() { case "feeds": return ( + ) + case "search": + return ( + { + // Would add to feeds + console.log("Subscribe to:", result.podcast.title) + }} + /> + ) + case "player": default: return ( @@ -176,7 +153,7 @@ export function App() { {tab}
- Content placeholder - coming in later phases + Player - coming in later phases
) @@ -184,17 +161,15 @@ export function App() { } return ( - - - } - footer={ - - } - > - {renderContent()} - - + + } + footer={ + + } + > + {renderContent()} + ) } diff --git a/src/components/CategoryFilter.tsx b/src/components/CategoryFilter.tsx new file mode 100644 index 0000000..18c03f2 --- /dev/null +++ b/src/components/CategoryFilter.tsx @@ -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 ( + + + {(category) => { + const isSelected = () => props.selectedCategory === category.id + + return ( + props.onSelect?.(category.id)} + > + + + {category.icon} {category.name} + + + + ) + }} + + + ) +} diff --git a/src/components/DiscoverPage.tsx b/src/components/DiscoverPage.tsx new file mode 100644 index 0000000..94ba2d8 --- /dev/null +++ b/src/components/DiscoverPage.tsx @@ -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("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 ( + + {/* Header */} + + + Discover Podcasts + + + + + {discoverStore.filteredPodcasts().length} shows + + + discoverStore.refresh()}> + + [R] Refresh + + + + + + {/* Category Filter */} + + + + + Categories: + + + + + + + {/* Trending Shows */} + + + + + Trending in { + DISCOVER_CATEGORIES.find( + (c) => c.id === discoverStore.selectedCategory() + )?.name ?? "All" + } + + + + + + + {/* Footer Hints */} + + + [Tab] Switch focus + + + [j/k] Navigate + + + [Enter] Subscribe + + + [R] Refresh + + + + ) +} diff --git a/src/components/FeedDetail.tsx b/src/components/FeedDetail.tsx new file mode 100644 index 0000000..104ea7a --- /dev/null +++ b/src/components/FeedDetail.tsx @@ -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 ( + + {/* Header with back button */} + + + + [Esc] Back + + + setShowInfo((v) => !v)} + > + + [i] {showInfo() ? "Hide" : "Show"} Info + + + + + {/* Podcast info section */} + + + + {props.feed.customName || props.feed.podcast.title} + + {props.feed.podcast.author && ( + + by + {props.feed.podcast.author} + + )} + + + + {props.feed.podcast.description?.slice(0, 200)} + {(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""} + + + + + + Episodes: + {props.feed.episodes.length} + + + Updated: + {formatDate(props.feed.lastUpdated)} + + + + {props.feed.visibility === "public" ? "[Public]" : "[Private]"} + + + {props.feed.isPinned && ( + + [Pinned] + + )} + + + + + {/* Episodes header */} + + + Episodes + ({episodes().length} total) + + + + {/* Episode list */} + + + {(episode, index) => ( + { + setSelectedIndex(index()) + if (props.onPlayEpisode) { + props.onPlayEpisode(episode) + } + }} + > + + + + {index() === selectedIndex() ? ">" : " "} + + + + + {episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""} + {episode.title} + + + + + + {formatDate(episode.pubDate)} + + + {formatDuration(episode.duration)} + + + + )} + + + + {/* Help text */} + + + j/k to navigate, Enter to play, i to toggle info, Esc to go back + + + + ) +} diff --git a/src/components/FeedList.tsx b/src/components/FeedList.tsx index 7515883..8479123 100644 --- a/src/components/FeedList.tsx +++ b/src/components/FeedList.tsx @@ -1,15 +1,14 @@ /** * 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 { FeedItem } from "./FeedItem" +import { useFeedStore } from "../stores/feed" import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed" -import { format } from "date-fns" interface FeedListProps { - feeds: Feed[] focused?: boolean compact?: boolean showEpisodeCount?: boolean @@ -19,73 +18,10 @@ interface FeedListProps { } export function FeedList(props: FeedListProps) { + const feedStore = useFeedStore() const [selectedIndex, setSelectedIndex] = createSignal(0) - const [filter, setFilter] = createSignal({ - visibility: "all", - sortBy: "updated" as FeedSortField, - sortDirection: "desc", - }) - /** Get filtered and sorted feeds */ - 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 filteredFeeds = () => feedStore.getFilteredFeeds() const handleKeyPress = (key: { name: string }) => { const feeds = filteredFeeds() @@ -99,7 +35,7 @@ export function FeedList(props: FeedListProps) { if (feed && props.onOpenFeed) { props.onOpenFeed(feed) } - } else if (key.name === "home") { + } else if (key.name === "home" || key.name === "g") { setSelectedIndex(0) } else if (key.name === "end") { setSelectedIndex(feeds.length - 1) @@ -107,6 +43,18 @@ export function FeedList(props: FeedListProps) { setSelectedIndex((i) => Math.max(0, i - 5)) } else if (key.name === "pagedown") { 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 @@ -116,40 +64,82 @@ export function FeedList(props: FeedListProps) { } } - const toggleVisibilityFilter = () => { - setFilter((f) => { - const current = f.visibility - let next: FeedVisibility | "all" - if (current === "all") next = "public" - else if (current === "public") next = "private" - else next = "all" - return { ...f, visibility: next } - }) + const cycleVisibilityFilter = () => { + const current = feedStore.filter().visibility + let next: FeedVisibility | "all" + if (current === "all") next = "public" + else if (current === "public") next = "private" + else next = "all" + 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 vis = filter().visibility + const vis = feedStore.filter().visibility if (vis === "all") return "All" if (vis === "public") return "Public" 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 ( - {/* Header with filter */} - + {/* Header with filter controls */} + My Feeds ({filteredFeeds().length} feeds) - - + + - [F] {visibilityLabel()} + [f] {visibilityLabel()} + + + + + [s] {sortLabel()} @@ -175,23 +165,28 @@ export function FeedList(props: FeedListProps) { > {(feed, index) => ( - + handleFeedClick(feed, index())} + onDoubleClick={() => handleFeedDoubleClick(feed)} + > + + )} {/* Navigation help */} - + - 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 diff --git a/src/components/KeyboardHandler.tsx b/src/components/KeyboardHandler.tsx index 66d3a53..0cc42e7 100644 --- a/src/components/KeyboardHandler.tsx +++ b/src/components/KeyboardHandler.tsx @@ -1,21 +1,17 @@ import type { JSX } from "solid-js" -import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts" import type { TabId } from "./Tab" +/** + * @deprecated Use useAppKeyboard hook directly instead. + * This component is kept for backwards compatibility. + */ type KeyboardHandlerProps = { children?: JSX.Element - onTabSelect: (tab: TabId) => void + onTabSelect?: (tab: TabId) => void } export function KeyboardHandler(props: KeyboardHandlerProps) { - useKeyboardShortcuts({ - onTabNext: () => { - props.onTabSelect("discover") - }, - onTabPrev: () => { - props.onTabSelect("settings") - }, - }) - + // Keyboard handling has been moved to useAppKeyboard hook + // This component is now just a passthrough return <>{props.children} } diff --git a/src/components/PodcastCard.tsx b/src/components/PodcastCard.tsx new file mode 100644 index 0000000..fbcc8b3 --- /dev/null +++ b/src/components/PodcastCard.tsx @@ -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 ( + + {/* Title Row */} + + + + {props.podcast.title} + + + + + + [+] + + + + + {/* Author */} + + + by {props.podcast.author} + + + + {/* Description */} + + + + {props.podcast.description!.length > 80 + ? props.podcast.description!.slice(0, 80) + "..." + : props.podcast.description} + + + + + {/* Categories and Subscribe Button */} + + + 0}> + {props.podcast.categories!.slice(0, 2).map((cat) => ( + + [{cat}] + + ))} + + + + + + + + {props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"} + + + + + + + ) +} diff --git a/src/components/SearchHistory.tsx b/src/components/SearchHistory.tsx new file mode 100644 index 0000000..1aab508 --- /dev/null +++ b/src/components/SearchHistory.tsx @@ -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 ( + + + + Recent Searches + + 0}> + props.onClear?.()} padding={0}> + + [Clear All] + + + + + + 0} + fallback={ + + + No recent searches + + + } + > + + + + {(query, index) => { + const isSelected = () => index() === props.selectedIndex && props.focused + + return ( + handleSearchClick(index(), query)} + > + + + {">"} + + + {query} + + + handleRemoveClick(e, query)} + padding={0} + > + + [x] + + + + ) + }} + + + + + + ) +} diff --git a/src/components/SearchPage.tsx b/src/components/SearchPage.tsx new file mode 100644 index 0000000..422efd2 --- /dev/null +++ b/src/components/SearchPage.tsx @@ -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("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 ( + + {/* Search Header */} + + + Search Podcasts + + + {/* Search Input */} + + + Search: + + props.onInputFocusChange?.(true)} + onBlur={() => props.onInputFocusChange?.(false)} + /> + + + [Enter] Search + + + + + {/* Status */} + + + Searching... + + + + + {searchStore.error()} + + + + + {/* Main Content - Results or History */} + + {/* Results Panel */} + + + + + Results ({searchStore.results().length}) + + + + 0} + fallback={ + + + + {searchStore.query() + ? "No results found" + : "Enter a search term to find podcasts"} + + + + } + > + + + + + {/* History Sidebar */} + + + + + + History + + + + + + + + + {/* Footer Hints */} + + + [Tab] Switch focus + + + [/] Focus search + + + [Enter] Select + + + [Esc] Back to search + + + + ) +} diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx new file mode 100644 index 0000000..2113f56 --- /dev/null +++ b/src/components/SearchResults.tsx @@ -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 ( + 0} + fallback={ + + + No results found. Try a different search term. + + + } + > + + + + {(result, index) => { + const isSelected = () => index() === props.selectedIndex + const podcast = result.podcast + + return ( + handleMouseDown(index(), result)} + > + + + + {podcast.title} + + + + + [Subscribed] + + + + ({result.sourceId}) + + + + + + by {podcast.author} + + + + + + + {podcast.description!.length > 100 + ? podcast.description!.slice(0, 100) + "..." + : podcast.description} + + + + + 0}> + + + {(category) => ( + + [{category}] + + )} + + + + + ) + }} + + + + + ) +} diff --git a/src/components/SourceManager.tsx b/src/components/SourceManager.tsx new file mode 100644 index 0000000..c34c694 --- /dev/null +++ b/src/components/SourceManager.tsx @@ -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("list") + const [newSourceUrl, setNewSourceUrl] = createSignal("") + const [newSourceName, setNewSourceName] = createSignal("") + const [error, setError] = createSignal(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 ( + + + + Podcast Sources + + + + [Esc] Close + + + + + + + Manage where to search for podcasts + + + + {/* Source list */} + + + Sources: + + + + {(source, index) => ( + { + setSelectedIndex(index()) + setFocusArea("list") + feedStore.toggleSource(source.id) + }} + > + + + {focusArea() === "list" && index() === selectedIndex() + ? ">" + : " "} + + + + + {source.enabled ? "[x]" : "[ ]"} + + + + {getSourceIcon(source)} + + + + {source.name} + + + + )} + + + + + Space/Enter to toggle, d to delete, a to add + + + + + {/* Add new source form */} + + + + Add New Source: + + + + + + Name: + + + + + + + URL: + + { + setNewSourceUrl(v) + setError(null) + }} + placeholder="https://example.com/feed.rss" + focused={props.focused && focusArea() === "url"} + width={35} + /> + + + + + [+] Add Source + + + + + {/* Error message */} + {error() && ( + + {error()} + + )} + + + Tab to switch sections, Esc to close + + + ) +} diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx index 791a0a6..9961a1f 100644 --- a/src/components/TabNavigation.tsx +++ b/src/components/TabNavigation.tsx @@ -1,4 +1,3 @@ -import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts" import { Tab, type TabId } from "./Tab" type TabNavigationProps = { @@ -7,23 +6,6 @@ type 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 ( diff --git a/src/components/TrendingShows.tsx b/src/components/TrendingShows.tsx new file mode 100644 index 0000000..85f3fb4 --- /dev/null +++ b/src/components/TrendingShows.tsx @@ -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 ( + + + + + Loading trending shows... + + + + + + + + No podcasts found in this category. + + + + + 0}> + + + + {(podcast, index) => ( + props.onSelect?.(index())} + onSubscribe={() => props.onSubscribe?.(podcast)} + /> + )} + + + + + + ) +} diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts new file mode 100644 index 0000000..6226825 --- /dev/null +++ b/src/hooks/useAppKeyboard.ts @@ -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") + } + } + }) +} diff --git a/src/stores/discover.ts b/src/stores/discover.ts new file mode 100644 index 0000000..272b21c --- /dev/null +++ b/src/stores/discover.ts @@ -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("all") + const [isLoading, setIsLoading] = createSignal(false) + const [podcasts, setPodcasts] = createSignal(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 | null = null + +export function useDiscoverStore() { + if (!discoverStoreInstance) { + discoverStoreInstance = createDiscoverStore() + } + return discoverStoreInstance +} diff --git a/src/stores/feed.ts b/src/stores/feed.ts new file mode 100644 index 0000000..ebdae12 --- /dev/null +++ b/src/stores/feed.ts @@ -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(loadFeeds()) + const [sources, setSources] = createSignal(loadSources()) + const [filter, setFilter] = createSignal({ + visibility: "all", + sortBy: "updated" as FeedSortField, + sortDirection: "desc", + }) + const [selectedFeedId, setSelectedFeedId] = createSignal(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) => { + 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) => { + 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 | null = null + +export function useFeedStore() { + if (!feedStoreInstance) { + feedStoreInstance = createFeedStore() + } + return feedStoreInstance +} diff --git a/src/stores/search.ts b/src/stores/search.ts new file mode 100644 index 0000000..e4c41a0 --- /dev/null +++ b/src/stores/search.ts @@ -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([]) + const [error, setError] = createSignal(null) + const [history, setHistory] = createSignal(loadHistory()) + const [selectedSources, setSelectedSources] = createSignal([]) + + /** Perform search (mock implementation) */ + const search = async (searchQuery: string): Promise => { + 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 | null = null + +export function useSearchStore() { + if (!searchStoreInstance) { + searchStoreInstance = createSearchStore() + } + return searchStoreInstance +} diff --git a/tasks/podcast-tui-app/README.md b/tasks/podcast-tui-app/README.md index b8db0c0..0eb514b 100644 --- a/tasks/podcast-tui-app/README.md +++ b/tasks/podcast-tui-app/README.md @@ -63,9 +63,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done - [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] 29 — Build feed list component (public/private feeds) → `29-feed-list.md` -- [ ] 30 — Implement feed source management (add/remove sources) → `30-source-management.md` -- [ ] 31 — Add reverse chronological ordering → `31-reverse-chronological.md` -- [ ] 32 — Create feed detail view → `32-feed-detail.md` +- [x] 30 — Implement feed source management (add/remove sources) → `30-source-management.md` +- [x] 31 — Add reverse chronological ordering → `31-reverse-chronological.md` +- [x] 32 — Create feed detail view → `32-feed-detail.md` **Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12