sketching out layout structure, cleaning Discover

This commit is contained in:
2026-02-07 16:44:49 -05:00
parent 73aa211229
commit 627fb65547
7 changed files with 173 additions and 298 deletions

View File

@@ -23,6 +23,7 @@ import { useToast } from "@/ui/toast";
import { useRenderer } from "@opentui/solid";
import type { AuthScreen } from "@/types/auth";
import type { Episode } from "@/types/episode";
import { DIRECTION } from "./types/navigation";
export function App() {
const [activeTab, setActiveTab] = createSignal<TabId>("feed");
@@ -61,26 +62,23 @@ export function App() {
onExit: () => setLayerDepth(0),
});
// Centralized keyboard handler for all tab navigation and shortcuts
useAppKeyboard({
get activeTab() {
return activeTab();
},
onTabChange: (tab: TabId) => {
setActiveTab(tab);
setInputFocused(false);
},
get inputFocused() {
return inputFocused();
},
get navigationEnabled() {
return layerDepth() === 0;
},
layerDepth,
onLayerChange: (newDepth) => {
setLayerDepth(newDepth);
},
onAction: (action) => {
onAction: (action, direction) => {
if (action == "cycle") {
if (direction == DIRECTION.Increment) {
//if()
}
if (direction == DIRECTION.Decrement) {
}
}
if (action == "depth") {
if (direction == DIRECTION.Increment) {
}
if (direction == DIRECTION.Decrement) {
}
}
if (action === "escape") {
if (layerDepth() > 0) {
setLayerDepth(0);
@@ -90,10 +88,6 @@ export function App() {
setInputFocused(false);
}
}
if (action === "enter" && layerDepth() === 0) {
setLayerDepth(1);
}
},
});

View File

@@ -3,145 +3,30 @@
* Single handler to prevent conflicts
*/
import { useKeyboard, useRenderer } from "@opentui/solid"
import type { Accessor } from "solid-js"
const TAB_ORDER: TabId[] = ["feed", "shows", "discover", "search", "player", "settings"]
type TabId =
| "feed"
| "shows"
| "discover"
| "search"
| "player"
| "settings"
import { TabId } from "@/components/TabNavigation";
import { useKeyboard, useRenderer } from "@opentui/solid";
import type { Accessor } from "solid-js";
type ShortcutOptions = {
activeTab: TabId
onTabChange: (tab: TabId) => void
onAction?: (action: string) => void
inputFocused?: boolean
navigationEnabled?: boolean
layerDepth?: Accessor<number>
onLayerChange?: (newDepth: number) => void
}
onAction?: (action: string, direction: Direction) => void;
layerDepth: Accessor<number>;
};
export function useAppKeyboard(options: ShortcutOptions) {
const renderer = useRenderer()
const getNextTab = (current: TabId): TabId => {
const idx = TAB_ORDER.indexOf(current)
return TAB_ORDER[(idx + 1) % TAB_ORDER.length]
}
const getPrevTab = (current: TabId): TabId => {
const idx = TAB_ORDER.indexOf(current)
return TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]
}
export function useAppKeyboard(props: ShortcutOptions) {
const renderer = useRenderer();
// layer depth 0 is tabs, they are oriented
// vertically, all others are vertically
useKeyboard((key) => {
// Always allow quit
if (key.ctrl && key.name === "q") {
renderer.destroy()
return
}
if (key.name === "escape") {
options.onAction?.("escape")
return
}
// Skip global shortcuts if input is focused (let input handle keys)
if (options.inputFocused) {
return
}
if (options.navigationEnabled === false) {
return
}
// Return key cycles tabs (equivalent to Tab)
if (key.name === "return") {
options.onTabChange(getNextTab(options.activeTab))
return
}
// Layer navigation with left/right arrows
if (options.layerDepth !== undefined && options.onLayerChange) {
const currentDepth = options.layerDepth()
const maxLayers = 3
if (key.name === "right") {
if (currentDepth < maxLayers) {
options.onLayerChange(currentDepth + 1)
}
return
}
if (key.name === "left") {
if (currentDepth > 0) {
options.onLayerChange(currentDepth - 1)
}
return
}
}
// Tab navigation with left/right arrows OR [ and ]
if (key.name === "right" || key.name === "]") {
options.onTabChange(getNextTab(options.activeTab))
return
}
if (key.name === "left" || key.name === "[") {
options.onTabChange(getPrevTab(options.activeTab))
return
}
// Number keys for direct tab access (1-6)
if (key.name === "1") {
options.onTabChange("feed")
return
}
if (key.name === "2") {
options.onTabChange("shows")
return
}
if (key.name === "3") {
options.onTabChange("discover")
return
}
if (key.name === "4") {
options.onTabChange("search")
return
}
if (key.name === "5") {
options.onTabChange("player")
return
}
if (key.name === "6") {
options.onTabChange("settings")
return
}
// Tab key cycles tabs (Shift+Tab goes backwards)
if (key.name === "tab") {
// handle cycle current layer
if (props.layerDepth() == 0) {
let reverse = false;
if (key.shift) {
options.onTabChange(getPrevTab(options.activeTab))
} else {
options.onTabChange(getNextTab(options.activeTab))
reverse = true;
}
return
}
// Forward other actions
if (options.onAction) {
if (key.ctrl && key.name === "s") {
options.onAction("save")
} else if (key.ctrl && key.name === "f") {
options.onAction("find")
} else if (key.name === "?" || (key.shift && key.name === "/")) {
options.onAction("help")
if (key.name == "tab" || key.name == "down" || key.name == "j") {
}
}
})
// handle cycle depth
});
}

View File

@@ -1,40 +0,0 @@
/**
* 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 fg={isSelected() ? "cyan" : "gray"}>
{category.icon} {category.name}
</text>
</box>
);
}}
</For>
</box>
);
}

View File

@@ -2,11 +2,11 @@
* DiscoverPage component - Main discover/browse interface for PodTUI
*/
import { createSignal } from "solid-js";
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { CategoryFilter } from "./CategoryFilter";
import { TrendingShows } from "./TrendingShows";
import { useTheme } from "@/context/ThemeContext";
import { PodcastCard } from "./PodcastCard";
type DiscoverPageProps = {
focused: boolean;
@@ -37,10 +37,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
return;
}
if (
key.name === "return" &&
area === "categories"
) {
if (key.name === "return" && area === "categories") {
setFocusArea("shows");
return;
}
@@ -141,42 +138,47 @@ export function DiscoverPage(props: DiscoverPageProps) {
discoverStore.toggleSubscription(podcast.id);
};
const { theme } = useTheme();
return (
<box flexDirection="column" height="100%" gap={1}>
{/* Header */}
<box flexDirection="row" flexGrow={1} height="100%" gap={1}>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
border
padding={1}
borderColor={theme.border}
flexDirection="column"
gap={1}
width={20}
>
<text>
<strong>Discover Podcasts</strong>
<text fg={focusArea() === "categories" ? theme.accent : theme.text}>
Categories:
</text>
<box flexDirection="row" gap={2}>
<text fg="gray">{discoverStore.filteredPodcasts().length} shows</text>
<box onMouseDown={() => discoverStore.refresh()}>
<text fg="cyan">[R] Refresh</text>
</box>
</box>
</box>
{/* Category Filter */}
<box border padding={1}>
<box flexDirection="column" gap={1}>
<text fg={focusArea() === "categories" ? "cyan" : "gray"}>
Categories:
</text>
<CategoryFilter
categories={discoverStore.categories}
selectedCategory={discoverStore.selectedCategory()}
focused={focusArea() === "categories"}
onSelect={handleCategorySelect}
/>
<For each={discoverStore.categories}>
{(category) => {
const isSelected = () =>
discoverStore.selectedCategory() === category.id;
return (
<box
border={isSelected()}
backgroundColor={isSelected() ? theme.accent : undefined}
onMouseDown={() => handleCategorySelect(category.id)}
>
<text fg={isSelected() ? "cyan" : "gray"}>
{category.icon} {category.name}
</text>
</box>
);
}}
</For>
</box>
</box>
{/* Trending Shows */}
<box flexDirection="column" flexGrow={1} border>
<box
flexDirection="column"
flexGrow={1}
border
borderColor={theme.border}
>
<box padding={1}>
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
Trending in{" "}
@@ -185,23 +187,40 @@ export function DiscoverPage(props: DiscoverPageProps) {
)?.name ?? "All"}
</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 fg="gray">[Tab] Switch focus</text>
<text fg="gray">[j/k] Navigate</text>
<text fg="gray">[Enter] Subscribe</text>
<text fg="gray">[Esc] Up</text>
<text fg="gray">[R] Refresh</text>
<box flexDirection="column" height="100%">
<Show
fallback={
<box padding={2}>
{discoverStore.filteredPodcasts().length !== 0 ? (
<text fg="yellow">Loading trending shows...</text>
) : (
<text fg="gray">No podcasts found in this category.</text>
)}
</box>
}
when={
!discoverStore.isLoading() &&
discoverStore.filteredPodcasts().length === 0
}
>
<scrollbox>
<box flexDirection="column">
<For each={discoverStore.filteredPodcasts()}>
{(podcast, index) => (
<PodcastCard
podcast={podcast}
selected={
index() === showIndex() && focusArea() === "shows"
}
onSelect={() => handleShowSelect(index())}
onSubscribe={() => handleSubscribe(podcast)}
/>
)}
</For>
</box>
</scrollbox>
</Show>
</box>
</box>
</box>
);

View File

@@ -1,51 +0,0 @@
/**
* 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 fg="yellow">Loading trending shows...</text>
</box>
</Show>
<Show when={!props.isLoading && props.podcasts.length === 0}>
<box padding={2}>
<text fg="gray">No podcasts found in this category.</text>
</box>
</Show>
<Show when={!props.isLoading && props.podcasts.length > 0}>
<scrollbox height={15}>
<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>
);
}

4
src/types/navigation.ts Normal file
View File

@@ -0,0 +1,4 @@
export enum DIRECTION {
Increment,
Decrement,
}

64
src/utils/navigation.ts Normal file
View File

@@ -0,0 +1,64 @@
enum FEEDTABTYPE {
LATEST,
}
export const FeedTab = {
[FEEDTABTYPE.LATEST]: {
size: 1,
title: "Feed - Latest Episodes",
scrolling: true,
},
};
enum MYSHOWSTYPE {
SHOWLIST,
EPISODELIST,
}
export const MyShowsTab = {
[MYSHOWSTYPE.SHOWLIST]: { size: 0.3, title: "My Shows", scrolling: true },
[MYSHOWSTYPE.EPISODELIST]: {
size: 0.7,
title: "<SHOW> - Episodes",
scrolling: true,
},
};
enum DiscoverTab {
CATEGORIES,
CATEGORYLIST,
}
export enum CATEGORIES {
ALL,
TECHNOLOGY,
SCIENCE,
COMEDY,
NEWS,
BUSINESS,
HEALTH,
EDUCATION,
SPORTS,
TRUECRIME,
ARTS,
}
export const SearchTab = [];
export const PlayerTab = [];
export const SettingsTab = [];
export enum TABS {
FEED,
MYSHOWS,
DISCOVER,
SEARCH,
PLAYER,
SETTINGS,
}
export const LayerGraph = {
[TABS.FEED]: FeedTab,
[TABS.MYSHOWS]: MyShowsTab,
[TABS.DISCOVER]: DiscoverTab,
[TABS.SEARCH]: SearchTab,
[TABS.PLAYER]: PlayerTab,
[TABS.SETTINGS]: SettingsTab,
};