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

View File

@@ -2,7 +2,8 @@ import { createSignal, ErrorBoundary } from "solid-js";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { Navigation } from "./components/Navigation"; import { Navigation } from "./components/Navigation";
import { TabNavigation } from "./components/TabNavigation"; import { TabNavigation } from "./components/TabNavigation";
import { FeedList } from "./components/FeedList"; import { FeedPage } from "./components/FeedPage";
import { MyShowsPage } from "./components/MyShowsPage";
import { LoginScreen } from "./components/LoginScreen"; import { LoginScreen } from "./components/LoginScreen";
import { CodeValidation } from "./components/CodeValidation"; import { CodeValidation } from "./components/CodeValidation";
import { OAuthPlaceholder } from "./components/OAuthPlaceholder"; import { OAuthPlaceholder } from "./components/OAuthPlaceholder";
@@ -20,7 +21,7 @@ import type { TabId } from "./components/Tab";
import type { AuthScreen } from "./types/auth"; import type { AuthScreen } from "./types/auth";
export function App() { export function App() {
const [activeTab, setActiveTab] = createSignal<TabId>("settings"); const [activeTab, setActiveTab] = createSignal<TabId>("feed");
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login"); const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
const [showAuthPanel, setShowAuthPanel] = createSignal(false); const [showAuthPanel, setShowAuthPanel] = createSignal(false);
const [inputFocused, setInputFocused] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false);
@@ -29,6 +30,15 @@ export function App() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const appStore = useAppStore(); const appStore = useAppStore();
// My Shows page returns panel renderers
const myShows = MyShowsPage({
get focused() { return activeTab() === "shows" && layerDepth() > 0 },
onPlayEpisode: (episode, feed) => {
// TODO: play episode
},
onExit: () => setLayerDepth(0),
});
// Centralized keyboard handler for all tab navigation and shortcuts // Centralized keyboard handler for all tab navigation and shortcuts
useAppKeyboard({ useAppKeyboard({
get activeTab() { get activeTab() {
@@ -58,28 +68,58 @@ export function App() {
}, },
}); });
const renderContent = () => { const getPanels = () => {
const tab = activeTab(); const tab = activeTab();
switch (tab) { switch (tab) {
case "feeds": case "feed":
return ( return {
<FeedList panels: [
{
title: "Feed - Latest Episodes",
content: (
<FeedPage
focused={layerDepth() > 0} focused={layerDepth() > 0}
showEpisodeCount={true} onPlayEpisode={(episode, feed) => {
showLastUpdated={true} // TODO: play episode
onFocusChange={() => setLayerDepth(0)}
onOpenFeed={(feed) => {
// Would open feed detail view
}} }}
onExit={() => setLayerDepth(0)}
/> />
); ),
},
],
activePanelIndex: 0,
hint: "j/k navigate | Enter play | r refresh | Esc back",
};
case "shows":
return {
panels: [
{
title: "My Shows",
width: 35,
content: myShows.showsPanel(),
focused: myShows.focusPane() === "shows",
},
{
title: myShows.selectedShow()
? `${myShows.selectedShow()!.podcast.title} - Episodes`
: "Episodes",
content: myShows.episodesPanel(),
focused: myShows.focusPane() === "episodes",
},
],
activePanelIndex: myShows.focusPane() === "shows" ? 0 : 1,
hint: "h/l switch panes | j/k navigate | Enter play | r refresh | d unsubscribe | Esc back",
};
case "settings": case "settings":
// Show auth panel or sync panel based on state
if (showAuthPanel()) { if (showAuthPanel()) {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
return ( return {
panels: [{
title: "Account",
content: (
<SyncProfile <SyncProfile
focused={layerDepth() > 0} focused={layerDepth() > 0}
onLogout={() => { onLogout={() => {
@@ -88,9 +128,14 @@ export function App() {
}} }}
onManageSync={() => setShowAuthPanel(false)} onManageSync={() => setShowAuthPanel(false)}
/> />
); ),
}],
activePanelIndex: 0,
hint: "Esc back",
};
} }
const authContent = () => {
switch (authScreen()) { switch (authScreen()) {
case "code": case "code":
return ( return (
@@ -107,7 +152,6 @@ export function App() {
onNavigateToCode={() => setAuthScreen("code")} onNavigateToCode={() => setAuthScreen("code")}
/> />
); );
case "login":
default: default:
return ( return (
<LoginScreen <LoginScreen
@@ -117,9 +161,22 @@ export function App() {
/> />
); );
} }
};
return {
panels: [{
title: "Sign In",
content: authContent(),
}],
activePanelIndex: 0,
hint: "Esc back",
};
} }
return ( return {
panels: [{
title: "Settings",
content: (
<SettingsScreen <SettingsScreen
onOpenAccount={() => setShowAuthPanel(true)} onOpenAccount={() => setShowAuthPanel(true)}
accountLabel={ accountLabel={
@@ -130,18 +187,32 @@ export function App() {
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"} accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"}
onExit={() => setLayerDepth(0)} onExit={() => setLayerDepth(0)}
/> />
); ),
}],
activePanelIndex: 0,
hint: "j/k navigate | Enter select | Esc back",
};
case "discover": case "discover":
return ( return {
panels: [{
title: "Discover",
content: (
<DiscoverPage <DiscoverPage
focused={layerDepth() > 0} focused={layerDepth() > 0}
onExit={() => setLayerDepth(0)} onExit={() => setLayerDepth(0)}
/> />
); ),
}],
activePanelIndex: 0,
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
};
case "search": case "search":
return ( return {
panels: [{
title: "Search",
content: (
<SearchPage <SearchPage
focused={layerDepth() > 0} focused={layerDepth() > 0}
onInputFocusChange={setInputFocused} onInputFocusChange={setInputFocused}
@@ -163,46 +234,62 @@ export function App() {
} }
}} }}
/> />
); ),
}],
activePanelIndex: 0,
hint: "Tab switch focus | / search | Enter select | Esc back",
};
case "player": case "player":
return ( return {
panels: [{
title: "Player",
content: (
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} /> <Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
); ),
}],
activePanelIndex: 0,
hint: "Space play/pause | Esc back",
};
default: default:
return ( return {
<box border style={{ padding: 2 }}> panels: [{
<text> title: tab,
<strong>{tab}</strong> content: (
<br /> <box padding={2}>
Coming soon <text>Coming soon</text>
</text>
</box> </box>
); ),
}],
activePanelIndex: 0,
hint: "",
};
} }
}; };
return ( return (
<Layout
layerDepth={layerDepth()}
header={
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
}
footer={<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />}
>
<box style={{ padding: 1 }}>
<ErrorBoundary fallback={(err) => ( <ErrorBoundary fallback={(err) => (
<box border padding={2}> <box border padding={2}>
<text fg="red"> <text fg="red">
Error rendering tab: {err?.message ?? String(err)}{"\n"} Error: {err?.message ?? String(err)}{"\n"}
Press a number key (1-5) to switch tabs. Press a number key (1-6) to switch tabs.
</text> </text>
</box> </box>
)}> )}>
{renderContent()} <Layout
</ErrorBoundary> header={
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
}
footer={
<box flexDirection="row" justifyContent="space-between" width="100%">
<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />
<text fg="gray">{getPanels().hint}</text>
</box> </box>
</Layout> }
panels={getPanels().panels}
activePanelIndex={getPanels().activePanelIndex}
/>
</ErrorBoundary>
); );
} }

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 { JSX } from "solid-js"
import type { RGBA } from "@opentui/core" 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 { useTheme } from "../context/ThemeContext"
import { LayerIndicator } from "./LayerIndicator"
type LayerConfig = { type PanelConfig = {
depth: number /** Panel content */
background: RGBA 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 = { type LayoutProps = {
/** Top tab bar */
header?: JSX.Element header?: JSX.Element
/** Bottom status bar */
footer?: JSX.Element footer?: JSX.Element
children?: JSX.Element /** Panels to display left-to-right like a file explorer */
layerDepth?: number panels: PanelConfig[]
/** Index of the currently active/focused panel */
activePanelIndex?: number
} }
export function Layout(props: LayoutProps) { export function Layout(props: LayoutProps) {
const context = useTheme() const context = useTheme()
// Get layer configuration based on depth - wrapped in createMemo for reactivity const panelBg = (index: number): RGBA => {
const currentLayer = createMemo((): LayerConfig => {
const depth = props.layerDepth || 0
const backgrounds = context.theme.layerBackgrounds const backgrounds = context.theme.layerBackgrounds
const depthMap: Record<number, LayerConfig> = { const layers = [
0: { depth: 0, background: backgrounds?.layer0 ?? context.theme.background }, backgrounds?.layer0 ?? context.theme.background,
1: { depth: 1, background: backgrounds?.layer1 ?? context.theme.backgroundPanel }, backgrounds?.layer1 ?? context.theme.backgroundPanel,
2: { depth: 2, background: backgrounds?.layer2 ?? context.theme.backgroundElement }, backgrounds?.layer2 ?? context.theme.backgroundElement,
3: { depth: 3, background: backgrounds?.layer3 ?? context.theme.backgroundMenu }, 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 ( return (
<box <box
flexDirection="column" flexDirection="column"
@@ -42,36 +53,74 @@ export function Layout(props: LayoutProps) {
height="100%" height="100%"
backgroundColor={context.theme.background} backgroundColor={context.theme.background}
> >
{/* Header */} {/* Header - tab bar */}
<Show when={props.header} fallback={<box style={{ height: 4 }} />}> <Show when={props.header}>
<box <box
style={{ style={{
height: 4, height: 3,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel, backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
}} }}
> >
<box style={{ padding: 1 }}> <box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
{props.header} {props.header}
</box> </box>
</box> </box>
</Show> </Show>
{/* Main content area with layer background */} {/* Main content: side-by-side panels */}
<box
flexDirection="row"
style={{ flexGrow: 1 }}
>
<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 <box
style={{ style={{
flexGrow: 1, flexGrow: 1,
backgroundColor: currentLayer().background, padding: 1,
paddingLeft: 2,
paddingRight: 2,
}} }}
> >
<box style={{ flexGrow: 1 }}> {panel.content}
{props.children}
</box> </box>
</box> </box>
)}
</For>
</box>
{/* Footer */} {/* Footer - status/nav bar */}
<Show when={props.footer} fallback={<box style={{ height: 2 }} />}> <Show when={props.footer}>
<box <box
style={{ style={{
height: 2, height: 2,
@@ -83,20 +132,6 @@ export function Layout(props: LayoutProps) {
</box> </box>
</box> </box>
</Show> </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> </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 ( return (
<box style={{ flexDirection: "row", width: "100%", height: 1 }}> <box style={{ flexDirection: "row", width: "100%", height: 1 }}>
<text> <text>
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "} {props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
<span> </span> <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> <span> </span>
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "} {props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
<span> </span> <span> </span>

View File

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

View File

@@ -8,8 +8,9 @@ type TabNavigationProps = {
export function TabNavigation(props: TabNavigationProps) { export function TabNavigation(props: TabNavigationProps) {
return ( return (
<box style={{ flexDirection: "row", gap: 1 }}> <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: "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: "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: "player", label: "Player" }} active={props.activeTab === "player"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} /> <Tab tab={{ id: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} />

View File

@@ -7,7 +7,7 @@ import { useKeyboard, useRenderer } from "@opentui/solid"
import type { TabId } from "../components/Tab" import type { TabId } from "../components/Tab"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
const TAB_ORDER: TabId[] = ["discover", "feeds", "search", "player", "settings"] const TAB_ORDER: TabId[] = ["feed", "shows", "discover", "search", "player", "settings"]
type ShortcutOptions = { type ShortcutOptions = {
activeTab: TabId activeTab: TabId
@@ -89,24 +89,28 @@ export function useAppKeyboard(options: ShortcutOptions) {
return return
} }
// Number keys for direct tab access (1-5) // Number keys for direct tab access (1-6)
if (key.name === "1") { if (key.name === "1") {
options.onTabChange("discover") options.onTabChange("feed")
return return
} }
if (key.name === "2") { if (key.name === "2") {
options.onTabChange("feeds") options.onTabChange("shows")
return return
} }
if (key.name === "3") { if (key.name === "3") {
options.onTabChange("search") options.onTabChange("discover")
return return
} }
if (key.name === "4") { if (key.name === "4") {
options.onTabChange("player") options.onTabChange("search")
return return
} }
if (key.name === "5") { if (key.name === "5") {
options.onTabChange("player")
return
}
if (key.name === "6") {
options.onTabChange("settings") options.onTabChange("settings")
return return
} }

View File

@@ -10,6 +10,13 @@ import type { Podcast } from "../types/podcast"
import type { Episode, EpisodeStatus } from "../types/episode" import type { Episode, EpisodeStatus } from "../types/episode"
import type { PodcastSource, SourceType } from "../types/source" import type { PodcastSource, SourceType } from "../types/source"
import { DEFAULT_SOURCES } from "../types/source" import { DEFAULT_SOURCES } from "../types/source"
import { parseRSSFeed } from "../api/rss-parser"
/** Max episodes to fetch on refresh */
const MAX_EPISODES_REFRESH = 50
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20
/** Storage keys */ /** Storage keys */
const STORAGE_KEYS = { const STORAGE_KEYS = {
@@ -17,125 +24,10 @@ const STORAGE_KEYS = {
sources: "podtui_sources", 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 */ /** Load feeds from localStorage */
function loadFeeds(): Feed[] { function loadFeeds(): Feed[] {
if (typeof localStorage === "undefined") { if (typeof localStorage === "undefined") {
return createMockFeeds() return []
} }
try { try {
@@ -160,7 +52,7 @@ function loadFeeds(): Feed[] {
// Ignore errors // Ignore errors
} }
return createMockFeeds() return []
} }
/** Save feeds to localStorage */ /** Save feeds to localStorage */
@@ -287,12 +179,31 @@ export function createFeedStore() {
return allEpisodes return allEpisodes
} }
/** Add a new feed */ /** Fetch latest episodes from an RSS feed URL */
const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => { const fetchEpisodes = async (feedUrl: string, limit: number): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl, {
headers: {
"Accept-Encoding": "identity",
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
})
if (!response.ok) return []
const xml = await response.text()
const parsed = parseRSSFeed(xml, feedUrl)
return parsed.episodes.slice(0, limit)
} catch {
return []
}
}
/** Add a new feed and auto-fetch latest 20 episodes */
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE)
const newFeed: Feed = { const newFeed: Feed = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
podcast, podcast,
episodes: [], episodes,
visibility, visibility,
sourceId, sourceId,
lastUpdated: new Date(), lastUpdated: new Date(),
@@ -306,6 +217,28 @@ export function createFeedStore() {
return newFeed return newFeed
} }
/** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId)
if (!feed) return
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH)
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f
)
saveFeeds(updated)
return updated
})
}
/** Refresh all feeds */
const refreshAllFeeds = async () => {
const currentFeeds = feeds()
for (const feed of currentFeeds) {
await refreshFeed(feed.id)
}
}
/** Remove a feed */ /** Remove a feed */
const removeFeed = (feedId: string) => { const removeFeed = (feedId: string) => {
setFeeds((prev) => { setFeeds((prev) => {
@@ -417,6 +350,8 @@ export function createFeedStore() {
removeFeed, removeFeed,
updateFeed, updateFeed,
togglePinned, togglePinned,
refreshFeed,
refreshAllFeeds,
addSource, addSource,
removeSource, removeSource,
toggleSource, toggleSource,