slight ui improvement

This commit is contained in:
2026-02-05 19:08:39 -05:00
parent f3344fbed2
commit e0fa76fb32
9 changed files with 732 additions and 304 deletions

121
src/components/FeedPage.tsx Normal file
View File

@@ -0,0 +1,121 @@
/**
* FeedPage - Shows latest episodes across all subscribed shows
* Reverse chronological order, like an inbox/timeline
*/
import { createSignal, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useFeedStore } from "../stores/feed"
import { format } from "date-fns"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
type FeedPageProps = {
focused: boolean
onPlayEpisode?: (episode: Episode, feed: Feed) => void
onExit?: () => void
}
export function FeedPage(props: FeedPageProps) {
const feedStore = useFeedStore()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [isRefreshing, setIsRefreshing] = createSignal(false)
const allEpisodes = () => feedStore.getAllEpisodesChronological()
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy")
}
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 handleRefresh = async () => {
setIsRefreshing(true)
await feedStore.refreshAllFeeds()
setIsRefreshing(false)
}
useKeyboard((key) => {
if (!props.focused) return
const episodes = allEpisodes()
if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1))
} else if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1))
} else if (key.name === "return" || key.name === "enter") {
const item = episodes[selectedIndex()]
if (item) props.onPlayEpisode?.(item.episode, item.feed)
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0)
} else if (key.name === "end") {
setSelectedIndex(episodes.length - 1)
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10))
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10))
} else if (key.name === "r") {
handleRefresh()
} else if (key.name === "escape") {
props.onExit?.()
}
})
return (
<box flexDirection="column" height="100%">
{/* Status line */}
<Show when={isRefreshing()}>
<text fg="yellow">Refreshing feeds...</text>
</Show>
{/* Episode list */}
<Show
when={allEpisodes().length > 0}
fallback={
<box padding={2}>
<text fg="gray">
No episodes yet. Subscribe to shows from Discover or Search.
</text>
</box>
}
>
<scrollbox height="100%" focused={props.focused}>
<For each={allEpisodes()}>
{(item, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
onMouseDown={() => setSelectedIndex(index())}
>
<box flexDirection="row" gap={1}>
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
{index() === selectedIndex() ? ">" : " "}
</text>
<text fg={index() === selectedIndex() ? "white" : undefined}>
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg="cyan">{item.feed.podcast.title}</text>
<text fg="gray">{formatDate(item.episode.pubDate)}</text>
<text fg="gray">{formatDuration(item.episode.duration)}</text>
</box>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
)
}

View File

@@ -1,40 +1,51 @@
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
import { Show, createMemo } from "solid-js"
import { Show, For, createMemo } from "solid-js"
import { useTheme } from "../context/ThemeContext"
import { LayerIndicator } from "./LayerIndicator"
type LayerConfig = {
depth: number
background: RGBA
type PanelConfig = {
/** Panel content */
content: JSX.Element
/** Panel title shown in header */
title?: string
/** Fixed width (leave undefined for flex) */
width?: number
/** Whether this panel is currently focused */
focused?: boolean
}
type LayoutProps = {
/** Top tab bar */
header?: JSX.Element
/** Bottom status bar */
footer?: JSX.Element
children?: JSX.Element
layerDepth?: number
/** Panels to display left-to-right like a file explorer */
panels: PanelConfig[]
/** Index of the currently active/focused panel */
activePanelIndex?: number
}
export function Layout(props: LayoutProps) {
const context = useTheme()
// Get layer configuration based on depth - wrapped in createMemo for reactivity
const currentLayer = createMemo((): LayerConfig => {
const depth = props.layerDepth || 0
const panelBg = (index: number): RGBA => {
const backgrounds = context.theme.layerBackgrounds
const depthMap: Record<number, LayerConfig> = {
0: { depth: 0, background: backgrounds?.layer0 ?? context.theme.background },
1: { depth: 1, background: backgrounds?.layer1 ?? context.theme.backgroundPanel },
2: { depth: 2, background: backgrounds?.layer2 ?? context.theme.backgroundElement },
3: { depth: 3, background: backgrounds?.layer3 ?? context.theme.backgroundMenu },
}
const layers = [
backgrounds?.layer0 ?? context.theme.background,
backgrounds?.layer1 ?? context.theme.backgroundPanel,
backgrounds?.layer2 ?? context.theme.backgroundElement,
backgrounds?.layer3 ?? context.theme.backgroundMenu,
]
return layers[Math.min(index, layers.length - 1)]
}
return depthMap[depth] || { depth: 0, background: context.theme.background }
})
const borderColor = (index: number): RGBA | string => {
const isActive = index === (props.activePanelIndex ?? 0)
return isActive
? (context.theme.accent ?? context.theme.primary)
: (context.theme.border ?? context.theme.textMuted)
}
// Note: No need for a ready check here - the ThemeProvider uses
// createSimpleContext which gates children rendering until ready
return (
<box
flexDirection="column"
@@ -42,36 +53,74 @@ export function Layout(props: LayoutProps) {
height="100%"
backgroundColor={context.theme.background}
>
{/* Header */}
<Show when={props.header} fallback={<box style={{ height: 4 }} />}>
{/* Header - tab bar */}
<Show when={props.header}>
<box
style={{
height: 4,
height: 3,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
}}
>
<box style={{ padding: 1 }}>
<box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
{props.header}
</box>
</box>
</Show>
{/* Main content area with layer background */}
{/* Main content: side-by-side panels */}
<box
style={{
flexGrow: 1,
backgroundColor: currentLayer().background,
paddingLeft: 2,
paddingRight: 2,
}}
flexDirection="row"
style={{ flexGrow: 1 }}
>
<box style={{ flexGrow: 1 }}>
{props.children}
</box>
<For each={props.panels}>
{(panel, index) => (
<box
flexDirection="column"
border
borderColor={borderColor(index())}
backgroundColor={panelBg(index())}
style={{
flexGrow: panel.width ? 0 : 1,
width: panel.width,
height: "100%",
}}
>
{/* Panel header */}
<Show when={panel.title}>
<box
style={{
height: 1,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: index() === (props.activePanelIndex ?? 0)
? (context.theme.accent ?? context.theme.primary)
: (context.theme.surface ?? context.theme.backgroundPanel),
}}
>
<text
fg={index() === (props.activePanelIndex ?? 0) ? "black" : undefined}
>
<strong>{panel.title}</strong>
</text>
</box>
</Show>
{/* Panel body */}
<box
style={{
flexGrow: 1,
padding: 1,
}}
>
{panel.content}
</box>
</box>
)}
</For>
</box>
{/* Footer */}
<Show when={props.footer} fallback={<box style={{ height: 2 }} />}>
{/* Footer - status/nav bar */}
<Show when={props.footer}>
<box
style={{
height: 2,
@@ -83,20 +132,6 @@ export function Layout(props: LayoutProps) {
</box>
</box>
</Show>
{/* Layer indicator */}
<Show when={props.layerDepth !== undefined}>
<box
style={{
height: 1,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
}}
>
<box style={{ padding: 1 }}>
<LayerIndicator layerDepth={props.layerDepth as number} />
</box>
</box>
</Show>
</box>
)
}

View File

@@ -0,0 +1,242 @@
/**
* MyShowsPage - Two-panel file-explorer style view
* Left panel: list of subscribed shows
* Right panel: episodes for the selected show
*/
import { createSignal, For, Show, createMemo } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useFeedStore } from "../stores/feed"
import { format } from "date-fns"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
type MyShowsPageProps = {
focused: boolean
onPlayEpisode?: (episode: Episode, feed: Feed) => void
onExit?: () => void
}
type FocusPane = "shows" | "episodes"
export function MyShowsPage(props: MyShowsPageProps) {
const feedStore = useFeedStore()
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
const [showIndex, setShowIndex] = createSignal(0)
const [episodeIndex, setEpisodeIndex] = createSignal(0)
const [isRefreshing, setIsRefreshing] = createSignal(false)
const shows = () => feedStore.getFilteredFeeds()
const selectedShow = createMemo(() => {
const s = shows()
const idx = showIndex()
return idx < s.length ? s[idx] : undefined
})
const episodes = createMemo(() => {
const show = selectedShow()
if (!show) return []
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
)
})
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy")
}
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 handleRefresh = async () => {
const show = selectedShow()
if (!show) return
setIsRefreshing(true)
await feedStore.refreshFeed(show.id)
setIsRefreshing(false)
}
const handleUnsubscribe = () => {
const show = selectedShow()
if (!show) return
feedStore.removeFeed(show.id)
setShowIndex((i) => Math.max(0, i - 1))
setEpisodeIndex(0)
}
useKeyboard((key) => {
if (!props.focused) return
const pane = focusPane()
// Navigate between panes
if (key.name === "right" || key.name === "l") {
if (pane === "shows" && selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
}
return
}
if (key.name === "left" || key.name === "h") {
if (pane === "episodes") {
setFocusPane("shows")
}
return
}
if (key.name === "tab") {
if (pane === "shows" && selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
} else {
setFocusPane("shows")
}
return
}
if (pane === "shows") {
const s = shows()
if (key.name === "down" || key.name === "j") {
setShowIndex((i) => Math.min(s.length - 1, i + 1))
setEpisodeIndex(0)
} else if (key.name === "up" || key.name === "k") {
setShowIndex((i) => Math.max(0, i - 1))
setEpisodeIndex(0)
} else if (key.name === "return" || key.name === "enter") {
if (selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
}
} else if (key.name === "d") {
handleUnsubscribe()
} else if (key.name === "r") {
handleRefresh()
} else if (key.name === "escape") {
props.onExit?.()
}
} else if (pane === "episodes") {
const eps = episodes()
if (key.name === "down" || key.name === "j") {
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1))
} else if (key.name === "up" || key.name === "k") {
setEpisodeIndex((i) => Math.max(0, i - 1))
} else if (key.name === "return" || key.name === "enter") {
const ep = eps[episodeIndex()]
const show = selectedShow()
if (ep && show) props.onPlayEpisode?.(ep, show)
} else if (key.name === "pageup") {
setEpisodeIndex((i) => Math.max(0, i - 10))
} else if (key.name === "pagedown") {
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10))
} else if (key.name === "r") {
handleRefresh()
} else if (key.name === "escape") {
setFocusPane("shows")
}
}
})
return {
showsPanel: () => (
<box flexDirection="column" height="100%">
<Show when={isRefreshing()}>
<text fg="yellow">Refreshing...</text>
</Show>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg="gray">
No shows yet. Subscribe from Discover or Search.
</text>
</box>
}
>
<scrollbox height="100%" focused={props.focused && focusPane() === "shows"}>
<For each={shows()}>
{(feed, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === showIndex() ? "#333" : undefined}
onMouseDown={() => {
setShowIndex(index())
setEpisodeIndex(0)
}}
>
<text fg={index() === showIndex() ? "cyan" : "gray"}>
{index() === showIndex() ? ">" : " "}
</text>
<text fg={index() === showIndex() ? "white" : undefined}>
{feed.customName || feed.podcast.title}
</text>
<text fg="gray">({feed.episodes.length})</text>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
),
episodesPanel: () => (
<box flexDirection="column" height="100%">
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg="gray">Select a show</text>
</box>
}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg="gray">No episodes. Press [r] to refresh.</text>
</box>
}
>
<scrollbox height="100%" focused={props.focused && focusPane() === "episodes"}>
<For each={episodes()}>
{(episode, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === episodeIndex() ? "#333" : undefined}
onMouseDown={() => setEpisodeIndex(index())}
>
<box flexDirection="row" gap={1}>
<text fg={index() === episodeIndex() ? "cyan" : "gray"}>
{index() === episodeIndex() ? ">" : " "}
</text>
<text fg={index() === episodeIndex() ? "white" : undefined}>
{episode.episodeNumber ? `#${episode.episodeNumber} ` : ""}
{episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg="gray">{formatDate(episode.pubDate)}</text>
<text fg="gray">{formatDuration(episode.duration)}</text>
</box>
</box>
)}
</For>
</scrollbox>
</Show>
</Show>
</box>
),
focusPane,
selectedShow,
}
}

View File

@@ -9,9 +9,11 @@ export function Navigation(props: NavigationProps) {
return (
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
<text>
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
<span> </span>
{props.activeTab === "feeds" ? "[" : " "}My Feeds{props.activeTab === "feeds" ? "]" : " "}
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
<span> </span>
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
<span> </span>
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
<span> </span>

View File

@@ -1,6 +1,6 @@
import { useTheme } from "../context/ThemeContext"
export type TabId = "discover" | "feeds" | "search" | "player" | "settings"
export type TabId = "feed" | "shows" | "discover" | "search" | "player" | "settings"
export type TabDefinition = {
id: TabId
@@ -8,8 +8,9 @@ export type TabDefinition = {
}
export const tabs: TabDefinition[] = [
{ id: "feed", label: "Feed" },
{ id: "shows", label: "My Shows" },
{ id: "discover", label: "Discover" },
{ id: "feeds", label: "My Feeds" },
{ id: "search", label: "Search" },
{ id: "player", label: "Player" },
{ id: "settings", label: "Settings" },

View File

@@ -8,8 +8,9 @@ type TabNavigationProps = {
export function TabNavigation(props: TabNavigationProps) {
return (
<box style={{ flexDirection: "row", gap: 1 }}>
<Tab tab={{ id: "feed", label: "Feed" }} active={props.activeTab === "feed"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "shows", label: "My Shows" }} active={props.activeTab === "shows"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "feeds", label: "My Feeds" }} active={props.activeTab === "feeds"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "search", label: "Search" }} active={props.activeTab === "search"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "player", label: "Player" }} active={props.activeTab === "player"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} />