sketching out layout structure, cleaning Discover
This commit is contained in:
38
src/App.tsx
38
src/App.tsx
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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"}>
|
||||
<text fg={focusArea() === "categories" ? theme.accent : theme.text}>
|
||||
Categories:
|
||||
</text>
|
||||
<CategoryFilter
|
||||
categories={discoverStore.categories}
|
||||
selectedCategory={discoverStore.selectedCategory()}
|
||||
focused={focusArea() === "categories"}
|
||||
onSelect={handleCategorySelect}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<For each={discoverStore.categories}>
|
||||
{(category) => {
|
||||
const isSelected = () =>
|
||||
discoverStore.selectedCategory() === category.id;
|
||||
|
||||
{/* Trending Shows */}
|
||||
<box flexDirection="column" flexGrow={1} border>
|
||||
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>
|
||||
<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 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>
|
||||
|
||||
{/* 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>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -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
4
src/types/navigation.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum DIRECTION {
|
||||
Increment,
|
||||
Decrement,
|
||||
}
|
||||
64
src/utils/navigation.ts
Normal file
64
src/utils/navigation.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user