fix keyboard, finish 05
This commit is contained in:
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
|
||||
* 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<FeedFilter>({
|
||||
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 (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
{/* Header with filter */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||
{/* Header with filter controls */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||
<text>
|
||||
<strong>My Feeds</strong>
|
||||
<span fg="gray"> ({filteredFeeds().length} feeds)</span>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box border padding={0} onMouseDown={toggleVisibilityFilter}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={cycleVisibilityFilter}
|
||||
>
|
||||
<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>
|
||||
</box>
|
||||
</box>
|
||||
@@ -175,23 +165,28 @@ export function FeedList(props: FeedListProps) {
|
||||
>
|
||||
<For each={filteredFeeds()}>
|
||||
{(feed, index) => (
|
||||
<FeedItem
|
||||
feed={feed}
|
||||
isSelected={index() === selectedIndex()}
|
||||
compact={props.compact}
|
||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||
showLastUpdated={props.showLastUpdated ?? true}
|
||||
/>
|
||||
<box
|
||||
onMouseDown={() => handleFeedClick(feed, index())}
|
||||
onDoubleClick={() => handleFeedDoubleClick(feed)}
|
||||
>
|
||||
<FeedItem
|
||||
feed={feed}
|
||||
isSelected={index() === selectedIndex()}
|
||||
compact={props.compact}
|
||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||
showLastUpdated={props.showLastUpdated ?? true}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
||||
{/* Navigation help */}
|
||||
<box paddingTop={1}>
|
||||
<box paddingTop={0}>
|
||||
<text>
|
||||
<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>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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 (
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user