Compare commits
33 Commits
19a1f1a43b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b7c4938c54 | |||
| 256f112512 | |||
| 8196ac8e31 | |||
| f003377f0d | |||
| 1618588a30 | |||
| c9a370a424 | |||
| b45e7bf538 | |||
| 1e6618211a | |||
| 1a5efceebd | |||
| 0c16353e2e | |||
| 8d350d9eb5 | |||
| cc09786592 | |||
| cedf099910 | |||
| d1e1dd28b4 | |||
| 1c65c85d02 | |||
| 8e0f90f449 | |||
| 91fcaa9b9e | |||
| 0bbb327b29 | |||
| 276732d2a9 | |||
| 72000b362d | |||
| 9a2b790897 | |||
| 2dfc96321b | |||
| 3d5bc84550 | |||
| f707594d0c | |||
| a405474f11 | |||
| ce022dc447 | |||
| 6053d4d02c | |||
| 64a2ba2751 | |||
| bcf248f7dd | |||
| 5bd393c9cd | |||
| 627fb65547 | |||
| 73aa211229 | |||
| 7eb49ac1c7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -27,10 +27,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
.eslintcache
|
.eslintcache
|
||||||
.cache
|
.cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.lockb
|
*.lock
|
||||||
|
|
||||||
# IntelliJ based IDEs
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "podcast-tui-app",
|
"name": "podcast-tui-app",
|
||||||
|
"version": "0.1.0",
|
||||||
"module": "src/index.tsx",
|
"module": "src/index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
431
src/App.tsx
431
src/App.tsx
@@ -1,104 +1,59 @@
|
|||||||
import { createSignal, createMemo, ErrorBoundary } from "solid-js";
|
import { createMemo, ErrorBoundary, Accessor } from "solid-js";
|
||||||
import { useSelectionHandler } from "@opentui/solid";
|
import { useKeyboard, useSelectionHandler } from "@opentui/solid";
|
||||||
import { Layout } from "./Layout";
|
|
||||||
import { TabNavigation } from "./components/TabNavigation";
|
import { TabNavigation } from "./components/TabNavigation";
|
||||||
import { FeedPage } from "@/tabs/Feed/FeedPage";
|
|
||||||
import { MyShowsPage } from "@/tabs/MyShows/MyShowsPage";
|
|
||||||
import { LoginScreen } from "@/tabs/Settings/LoginScreen";
|
|
||||||
import { CodeValidation } from "@/components/CodeValidation";
|
import { CodeValidation } from "@/components/CodeValidation";
|
||||||
import { OAuthPlaceholder } from "@/tabs/Settings/OAuthPlaceholder";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { SyncProfile } from "@/tabs/Settings/SyncProfile";
|
|
||||||
import { SearchPage } from "@/tabs/Search/SearchPage";
|
|
||||||
import { DiscoverPage } from "@/tabs/Discover/DiscoverPage";
|
|
||||||
import { Player } from "@/tabs/Player/Player";
|
|
||||||
import { SettingsScreen } from "@/tabs/Settings/SettingsScreen";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
||||||
import { FeedVisibility } from "@/types/feed";
|
import { FeedVisibility } from "@/types/feed";
|
||||||
import { useAppKeyboard } from "@/hooks/useAppKeyboard";
|
|
||||||
import { Clipboard } from "@/utils/clipboard";
|
import { Clipboard } from "@/utils/clipboard";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { useRenderer } from "@opentui/solid";
|
import { useRenderer } from "@opentui/solid";
|
||||||
import type { TabId } from "@/components/Tab";
|
|
||||||
import type { AuthScreen } from "@/types/auth";
|
import type { AuthScreen } from "@/types/auth";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
|
import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation";
|
||||||
|
import { useTheme, ThemeProvider } from "./context/ThemeContext";
|
||||||
|
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
|
||||||
|
import { NavigationProvider, useNavigation } from "./context/NavigationContext";
|
||||||
|
import { useAudioNavStore, AudioSource } from "./stores/audio-nav";
|
||||||
|
|
||||||
|
const DEBUG = import.meta.env.DEBUG;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = createSignal<TabId>("feed");
|
const nav = useNavigation();
|
||||||
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
|
|
||||||
const [showAuthPanel, setShowAuthPanel] = createSignal(false);
|
|
||||||
const [inputFocused, setInputFocused] = createSignal(false);
|
|
||||||
const [layerDepth, setLayerDepth] = createSignal(0);
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
|
const themeContext = useTheme();
|
||||||
|
const theme = themeContext.theme;
|
||||||
|
|
||||||
|
// Create a reactive expression for background color
|
||||||
|
const backgroundColor = () => {
|
||||||
|
return themeContext.selected === "system"
|
||||||
|
? "transparent"
|
||||||
|
: themeContext.theme.surface;
|
||||||
|
};
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
|
|
||||||
// Global multimedia key handling — active when Player tab is NOT
|
|
||||||
// focused (Player.tsx handles its own keys when focused).
|
|
||||||
useMultimediaKeys({
|
useMultimediaKeys({
|
||||||
playerFocused: () => activeTab() === "player" && layerDepth() > 0,
|
playerFocused: () =>
|
||||||
inputFocused: () => inputFocused(),
|
nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0,
|
||||||
|
inputFocused: () => nav.inputFocused(),
|
||||||
hasEpisode: () => !!audio.currentEpisode(),
|
hasEpisode: () => !!audio.currentEpisode(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePlayEpisode = (episode: Episode) => {
|
const handlePlayEpisode = (episode: Episode) => {
|
||||||
audio.play(episode);
|
audio.play(episode);
|
||||||
setActiveTab("player");
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
setLayerDepth(1);
|
nav.setActiveDepth(1);
|
||||||
|
audioNav.setSource(AudioSource.FEED);
|
||||||
};
|
};
|
||||||
|
|
||||||
// My Shows page returns panel renderers
|
|
||||||
const myShows = MyShowsPage({
|
|
||||||
get focused() {
|
|
||||||
return activeTab() === "shows" && layerDepth() > 0;
|
|
||||||
},
|
|
||||||
onPlayEpisode: (episode, feed) => {
|
|
||||||
handlePlayEpisode(episode);
|
|
||||||
},
|
|
||||||
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) => {
|
|
||||||
if (action === "escape") {
|
|
||||||
if (layerDepth() > 0) {
|
|
||||||
setLayerDepth(0);
|
|
||||||
setInputFocused(false);
|
|
||||||
} else {
|
|
||||||
setShowAuthPanel(false);
|
|
||||||
setInputFocused(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "enter" && layerDepth() === 0) {
|
|
||||||
setLayerDepth(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy selected text to clipboard when selection ends (mouse release)
|
|
||||||
useSelectionHandler((selection: any) => {
|
useSelectionHandler((selection: any) => {
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
const text = selection.getSelectedText?.();
|
const text = selection.getSelectedText?.();
|
||||||
@@ -114,230 +69,75 @@ export function App() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const getPanels = createMemo(() => {
|
useKeyboard(
|
||||||
const tab = activeTab();
|
(keyEvent) => {
|
||||||
|
const isCycle = keybind.match("cycle", keyEvent);
|
||||||
|
const isUp = keybind.match("up", keyEvent);
|
||||||
|
const isDown = keybind.match("down", keyEvent);
|
||||||
|
const isLeft = keybind.match("left", keyEvent);
|
||||||
|
const isRight = keybind.match("right", keyEvent);
|
||||||
|
const isDive = keybind.match("dive", keyEvent);
|
||||||
|
const isOut = keybind.match("out", keyEvent);
|
||||||
|
const isToggle = keybind.match("audio-toggle", keyEvent);
|
||||||
|
const isNext = keybind.match("audio-next", keyEvent);
|
||||||
|
const isPrev = keybind.match("audio-prev", keyEvent);
|
||||||
|
const isSeekForward = keybind.match("audio-seek-forward", keyEvent);
|
||||||
|
const isSeekBackward = keybind.match("audio-seek-backward", keyEvent);
|
||||||
|
const isQuit = keybind.match("quit", keyEvent);
|
||||||
|
const isInverting = keybind.isInverting(keyEvent);
|
||||||
|
|
||||||
switch (tab) {
|
// unified navigation: left->right, top->bottom across all tabs
|
||||||
case "feed":
|
if (nav.activeDepth() == 0) {
|
||||||
return {
|
// at top level: cycle through tabs
|
||||||
panels: [
|
if (
|
||||||
{
|
(isCycle && !isInverting) ||
|
||||||
title: "Feed - Latest Episodes",
|
(isDown && !isInverting) ||
|
||||||
content: (
|
(isUp && isInverting)
|
||||||
<FeedPage
|
) {
|
||||||
focused={layerDepth() > 0}
|
nav.nextTab();
|
||||||
onPlayEpisode={(episode, feed) => {
|
return;
|
||||||
handlePlayEpisode(episode);
|
|
||||||
}}
|
|
||||||
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":
|
|
||||||
if (showAuthPanel()) {
|
|
||||||
if (auth.isAuthenticated) {
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Account",
|
|
||||||
content: (
|
|
||||||
<SyncProfile
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onLogout={() => {
|
|
||||||
auth.logout();
|
|
||||||
setShowAuthPanel(false);
|
|
||||||
}}
|
|
||||||
onManageSync={() => setShowAuthPanel(false)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Esc back",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authContent = () => {
|
|
||||||
switch (authScreen()) {
|
|
||||||
case "code":
|
|
||||||
return (
|
|
||||||
<CodeValidation
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onBack={() => setAuthScreen("login")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "oauth":
|
|
||||||
return (
|
|
||||||
<OAuthPlaceholder
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onBack={() => setAuthScreen("login")}
|
|
||||||
onNavigateToCode={() => setAuthScreen("code")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<LoginScreen
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onNavigateToCode={() => setAuthScreen("code")}
|
|
||||||
onNavigateToOAuth={() => setAuthScreen("oauth")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Sign In",
|
|
||||||
content: authContent(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Esc back",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
return {
|
(isCycle && isInverting) ||
|
||||||
panels: [
|
(isDown && isInverting) ||
|
||||||
{
|
(isUp && !isInverting)
|
||||||
title: "Settings",
|
) {
|
||||||
content: (
|
nav.prevTab();
|
||||||
<SettingsScreen
|
return;
|
||||||
onOpenAccount={() => setShowAuthPanel(true)}
|
}
|
||||||
accountLabel={
|
// dive out to first pane
|
||||||
auth.isAuthenticated
|
if (
|
||||||
? `Signed in as ${auth.user?.email}`
|
(isDive && !isInverting) ||
|
||||||
: "Not signed in"
|
(isOut && isInverting) ||
|
||||||
}
|
(isRight && !isInverting) ||
|
||||||
accountStatus={
|
(isLeft && isInverting)
|
||||||
auth.isAuthenticated ? "signed-in" : "signed-out"
|
) {
|
||||||
}
|
nav.setActiveDepth(1);
|
||||||
onExit={() => setLayerDepth(0)}
|
}
|
||||||
/>
|
} else {
|
||||||
),
|
// in panes: navigate between them
|
||||||
},
|
if (
|
||||||
],
|
(isDive && isInverting) ||
|
||||||
activePanelIndex: 0,
|
(isOut && !isInverting) ||
|
||||||
hint: "j/k navigate | Enter select | Esc back",
|
(isRight && isInverting) ||
|
||||||
};
|
(isLeft && !isInverting)
|
||||||
|
) {
|
||||||
case "discover":
|
nav.setActiveDepth(0);
|
||||||
return {
|
} else if (isDown && !isInverting) {
|
||||||
panels: [
|
nav.nextPane();
|
||||||
{
|
} else if (isUp && isInverting) {
|
||||||
title: "Discover",
|
nav.prevPane();
|
||||||
content: (
|
}
|
||||||
<DiscoverPage
|
}
|
||||||
focused={layerDepth() > 0}
|
},
|
||||||
onExit={() => setLayerDepth(0)}
|
{ release: false },
|
||||||
/>
|
);
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
case "search":
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Search",
|
|
||||||
content: (
|
|
||||||
<SearchPage
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onInputFocusChange={setInputFocused}
|
|
||||||
onExit={() => setLayerDepth(0)}
|
|
||||||
onSubscribe={(result) => {
|
|
||||||
const feeds = feedStore.feeds();
|
|
||||||
const alreadySubscribed = feeds.some(
|
|
||||||
(feed) =>
|
|
||||||
feed.podcast.id === result.podcast.id ||
|
|
||||||
feed.podcast.feedUrl === result.podcast.feedUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!alreadySubscribed) {
|
|
||||||
feedStore.addFeed(
|
|
||||||
{ ...result.podcast, isSubscribed: true },
|
|
||||||
result.sourceId,
|
|
||||||
FeedVisibility.PUBLIC,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Tab switch focus | / search | Enter select | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
case "player":
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Player",
|
|
||||||
content: (
|
|
||||||
<Player
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onExit={() => setLayerDepth(0)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Space play/pause | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: tab,
|
|
||||||
content: (
|
|
||||||
<box padding={2}>
|
|
||||||
<text>Coming soon</text>
|
|
||||||
</box>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(err) => (
|
fallback={(err) => (
|
||||||
<box border padding={2}>
|
<box border padding={2} borderColor={theme.error}>
|
||||||
<text fg="red">
|
<text fg={theme.error}>
|
||||||
Error: {err?.message ?? String(err)}
|
Error: {err?.message ?? String(err)}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
Press a number key (1-6) to switch tabs.
|
Press a number key (1-6) to switch tabs.
|
||||||
@@ -345,13 +145,50 @@ export function App() {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Layout
|
<box
|
||||||
header={
|
flexDirection="column"
|
||||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
backgroundColor={
|
||||||
|
themeContext.selected === "system"
|
||||||
|
? "transparent"
|
||||||
|
: themeContext.theme.surface
|
||||||
}
|
}
|
||||||
panels={getPanels().panels}
|
>
|
||||||
activePanelIndex={getPanels().activePanelIndex}
|
<LoadingIndicator />
|
||||||
/>
|
{DEBUG && (
|
||||||
|
<box flexDirection="row" width="100%" height={1}>
|
||||||
|
<text fg={theme.primary}>█</text>
|
||||||
|
<text fg={theme.secondary}>█</text>
|
||||||
|
<text fg={theme.accent}>█</text>
|
||||||
|
<text fg={theme.error}>█</text>
|
||||||
|
<text fg={theme.warning}>█</text>
|
||||||
|
<text fg={theme.success}>█</text>
|
||||||
|
<text fg={theme.info}>█</text>
|
||||||
|
<text fg={theme.text}>█</text>
|
||||||
|
<text fg={theme.textMuted}>█</text>
|
||||||
|
<text fg={theme.surface}>█</text>
|
||||||
|
<text fg={theme.background}>█</text>
|
||||||
|
<text fg={theme.border}>█</text>
|
||||||
|
<text fg={theme.borderActive}>█</text>
|
||||||
|
<text fg={theme.diffAdded}>█</text>
|
||||||
|
<text fg={theme.diffRemoved}>█</text>
|
||||||
|
<text fg={theme.diffContext}>█</text>
|
||||||
|
<text fg={theme.markdownText}>█</text>
|
||||||
|
<text fg={theme.markdownHeading}>█</text>
|
||||||
|
<text fg={theme.markdownLink}>█</text>
|
||||||
|
<text fg={theme.markdownCode}>█</text>
|
||||||
|
<text fg={theme.syntaxKeyword}>█</text>
|
||||||
|
<text fg={theme.syntaxString}>█</text>
|
||||||
|
<text fg={theme.syntaxNumber}>█</text>
|
||||||
|
<text fg={theme.syntaxFunction}>█</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
<box flexDirection="row" width="100%" height="100%">
|
||||||
|
<TabNavigation />
|
||||||
|
{LayerGraph[nav.activeTab()]()}
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
124
src/Layout.tsx
124
src/Layout.tsx
@@ -1,124 +0,0 @@
|
|||||||
import type { JSX } from "solid-js";
|
|
||||||
import type { RGBA } from "@opentui/core";
|
|
||||||
import { Show, For } from "solid-js";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
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;
|
|
||||||
/** 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 panelBg = (index: number): RGBA => {
|
|
||||||
const backgrounds = theme.layerBackgrounds;
|
|
||||||
const layers = [
|
|
||||||
backgrounds?.layer0 ?? theme.background,
|
|
||||||
backgrounds?.layer1 ?? theme.backgroundPanel,
|
|
||||||
backgrounds?.layer2 ?? theme.backgroundElement,
|
|
||||||
backgrounds?.layer3 ?? theme.backgroundMenu,
|
|
||||||
];
|
|
||||||
return layers[Math.min(index, layers.length - 1)];
|
|
||||||
};
|
|
||||||
|
|
||||||
const borderColor = (index: number): RGBA | string => {
|
|
||||||
const isActive = index === (props.activePanelIndex ?? 0);
|
|
||||||
return isActive
|
|
||||||
? (theme.accent ?? theme.primary)
|
|
||||||
: (theme.border ?? theme.textMuted);
|
|
||||||
};
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
backgroundColor={theme.surface}
|
|
||||||
>
|
|
||||||
{/* Header - tab bar */}
|
|
||||||
<Show when={props.header}>
|
|
||||||
<box
|
|
||||||
style={{
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: theme.surface ?? theme.backgroundPanel,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
|
|
||||||
{props.header}
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Main content: side-by-side panels */}
|
|
||||||
<box flexDirection="row" style={{ flexGrow: 1 }}>
|
|
||||||
<For each={props.panels}>
|
|
||||||
{(panel, index) => (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
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)
|
|
||||||
? (theme.accent ?? theme.primary)
|
|
||||||
: (theme.surface ?? 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>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { AUTH_CONFIG } from "@/config/auth";
|
import { AUTH_CONFIG } from "@/config/auth";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
interface CodeValidationProps {
|
interface CodeValidationProps {
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
@@ -16,6 +17,7 @@ type FocusField = "code" | "submit" | "back";
|
|||||||
|
|
||||||
export function CodeValidation(props: CodeValidationProps) {
|
export function CodeValidation(props: CodeValidationProps) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const { theme } = useTheme();
|
||||||
const [code, setCode] = createSignal("");
|
const [code, setCode] = createSignal("");
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
||||||
const [codeError, setCodeError] = createSignal<string | null>(null);
|
const [codeError, setCodeError] = createSignal<string | null>(null);
|
||||||
@@ -72,7 +74,7 @@ export function CodeValidation(props: CodeValidationProps) {
|
|||||||
? (currentIndex - 1 + fields.length) % fields.length
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
: (currentIndex + 1) % fields.length;
|
: (currentIndex + 1) % fields.length;
|
||||||
setFocusField(fields[nextIndex]);
|
setFocusField(fields[nextIndex]);
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
} else if (key.name === "return" || key.name === "tab") {
|
||||||
if (focusField() === "submit") {
|
if (focusField() === "submit") {
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
} else if (focusField() === "back" && props.onBack) {
|
} else if (focusField() === "back" && props.onBack) {
|
||||||
@@ -98,32 +100,32 @@ export function CodeValidation(props: CodeValidationProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border padding={2} gap={1}>
|
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<strong>Enter Sync Code</strong>
|
<strong>Enter Sync Code</strong>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
<text fg="gray">
|
<text fg={theme.textMuted}>
|
||||||
Enter your 8-character sync code to link your account.
|
Enter your 8-character sync code to link your account.
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">You can get this code from the web portal.</text>
|
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
{/* Code display */}
|
{/* Code display */}
|
||||||
<box flexDirection="column" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg={focusField() === "code" ? "cyan" : undefined}>
|
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
||||||
Code ({codeProgress()}):
|
Code ({codeProgress()}):
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<box border padding={1}>
|
<box border padding={1} borderColor={theme.border}>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
code().length === AUTH_CONFIG.codeValidation.codeLength
|
code().length === AUTH_CONFIG.codeValidation.codeLength
|
||||||
? "green"
|
? theme.success
|
||||||
: "yellow"
|
: theme.warning
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{codeDisplay()}
|
{codeDisplay()}
|
||||||
@@ -139,7 +141,7 @@ export function CodeValidation(props: CodeValidationProps) {
|
|||||||
width={30}
|
width={30}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{codeError() && <text fg="red">{codeError()}</text>}
|
{codeError() && <text fg={theme.error}>{codeError()}</text>}
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
@@ -149,9 +151,9 @@ export function CodeValidation(props: CodeValidationProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "submit" ? "#333" : undefined}
|
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusField() === "submit" ? "cyan" : undefined}>
|
<text fg={focusField() === "submit" ? theme.primary : undefined}>
|
||||||
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -159,20 +161,20 @@ export function CodeValidation(props: CodeValidationProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusField() === "back" ? "yellow" : "gray"}>
|
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
||||||
[Esc] Back to Login
|
[Esc] Back to Login
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Auth error message */}
|
{/* Auth error message */}
|
||||||
{auth.error && <text fg="red">{auth.error.message}</text>}
|
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/components/LoadingIndicator.tsx
Normal file
24
src/components/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createSignal, createMemo, onCleanup } from "solid-js";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
|
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
|
//TODO: Watch for actual loading state (fetching feeds)
|
||||||
|
export function LoadingIndicator() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [index, setIndex] = createSignal(0);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setIndex((i) => (i + 1) % spinnerChars.length);
|
||||||
|
}, 65);
|
||||||
|
|
||||||
|
onCleanup(() => clearInterval(interval));
|
||||||
|
|
||||||
|
const currentChar = createMemo(() => spinnerChars[index()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start">
|
||||||
|
<text fg={theme.primary} content={currentChar()} />
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TabId } from "./Tab"
|
import type { TabId } from "./Tab"
|
||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
type NavigationProps = {
|
type NavigationProps = {
|
||||||
activeTab: TabId
|
activeTab: TabId
|
||||||
@@ -6,9 +7,10 @@ type NavigationProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
export function Navigation(props: NavigationProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
|
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
|
||||||
<span> </span>
|
<span> </span>
|
||||||
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
|
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
|
||||||
|
|||||||
81
src/components/Selectable.tsx
Normal file
81
src/components/Selectable.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { children as solidChildren } from "solid-js";
|
||||||
|
import type { ParentComponent } from "solid-js";
|
||||||
|
import type { BoxOptions, TextOptions } from "@opentui/core";
|
||||||
|
|
||||||
|
export const SelectableBox: ParentComponent<
|
||||||
|
{
|
||||||
|
selected: () => boolean;
|
||||||
|
} & BoxOptions
|
||||||
|
> = (props) => {
|
||||||
|
const themeContext = useTheme();
|
||||||
|
const { theme } = themeContext;
|
||||||
|
|
||||||
|
const child = solidChildren(() => props.children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border={!!props.border}
|
||||||
|
borderColor={props.selected() ? theme.surface : theme.border}
|
||||||
|
backgroundColor={
|
||||||
|
props.selected()
|
||||||
|
? theme.primary
|
||||||
|
: themeContext.selected === "system"
|
||||||
|
? "transparent"
|
||||||
|
: themeContext.theme.surface
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{child()}
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
enum ColorSet {
|
||||||
|
PRIMARY,
|
||||||
|
SECONDARY,
|
||||||
|
TERTIARY,
|
||||||
|
DEFAULT,
|
||||||
|
}
|
||||||
|
function getTextColor(set: ColorSet, selected: () => boolean) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
switch (set) {
|
||||||
|
case ColorSet.PRIMARY:
|
||||||
|
return selected() ? theme.textSelectedPrimary : theme.textPrimary;
|
||||||
|
case ColorSet.SECONDARY:
|
||||||
|
return selected() ? theme.textSelectedSecondary : theme.textSecondary;
|
||||||
|
case ColorSet.TERTIARY:
|
||||||
|
return selected() ? theme.textSelectedTertiary : theme.textTertiary;
|
||||||
|
default:
|
||||||
|
return theme.textPrimary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectableText: ParentComponent<
|
||||||
|
{
|
||||||
|
selected: () => boolean;
|
||||||
|
primary?: boolean;
|
||||||
|
secondary?: boolean;
|
||||||
|
tertiary?: boolean;
|
||||||
|
} & TextOptions
|
||||||
|
> = (props) => {
|
||||||
|
const child = solidChildren(() => props.children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
fg={getTextColor(
|
||||||
|
props.primary
|
||||||
|
? ColorSet.PRIMARY
|
||||||
|
: props.secondary
|
||||||
|
? ColorSet.SECONDARY
|
||||||
|
: props.tertiary
|
||||||
|
? ColorSet.TERTIARY
|
||||||
|
: ColorSet.DEFAULT,
|
||||||
|
props.selected,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{child()}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
import { shortcuts } from "@/config/shortcuts";
|
import { shortcuts } from "@/config/shortcuts";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
export function ShortcutHelp() {
|
export function ShortcutHelp() {
|
||||||
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box border title="Shortcuts" style={{ padding: 1 }}>
|
<box border title="Shortcuts" style={{ padding: 1 }}>
|
||||||
<box style={{ flexDirection: "column" }}>
|
<box style={{ flexDirection: "column" }}>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<box style={{ flexDirection: "row" }}>
|
||||||
<text>{shortcuts[0]?.keys ?? ""} </text>
|
<text fg={theme.text}>{shortcuts[0]?.keys ?? ""} </text>
|
||||||
<text>{shortcuts[0]?.action ?? ""}</text>
|
<text fg={theme.text}>{shortcuts[0]?.action ?? ""}</text>
|
||||||
</box>
|
</box>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<box style={{ flexDirection: "row" }}>
|
||||||
<text>{shortcuts[1]?.keys ?? ""} </text>
|
<text fg={theme.text}>{shortcuts[1]?.keys ?? ""} </text>
|
||||||
<text>{shortcuts[1]?.action ?? ""}</text>
|
<text fg={theme.text}>{shortcuts[1]?.action ?? ""}</text>
|
||||||
</box>
|
</box>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<box style={{ flexDirection: "row" }}>
|
||||||
<text>{shortcuts[2]?.keys ?? ""} </text>
|
<text fg={theme.text}>{shortcuts[2]?.keys ?? ""} </text>
|
||||||
<text>{shortcuts[2]?.action ?? ""}</text>
|
<text fg={theme.text}>{shortcuts[2]?.action ?? ""}</text>
|
||||||
</box>
|
</box>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<box style={{ flexDirection: "row" }}>
|
||||||
<text>{shortcuts[3]?.keys ?? ""} </text>
|
<text fg={theme.text}>{shortcuts[3]?.keys ?? ""} </text>
|
||||||
<text>{shortcuts[3]?.action ?? ""}</text>
|
<text fg={theme.text}>{shortcuts[3]?.action ?? ""}</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,57 +1,55 @@
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { TABS, TabsCount } from "@/utils/navigation";
|
||||||
import { For } from "solid-js";
|
import { For } from "solid-js";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
interface TabNavigationProps {
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
activeTab: TabId;
|
|
||||||
onTabSelect: (tab: TabId) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tabs: TabDefinition[] = [
|
export const tabs: TabDefinition[] = [
|
||||||
{ id: "feed", label: "Feed" },
|
{ id: TABS.FEED, label: "Feed" },
|
||||||
{ id: "shows", label: "My Shows" },
|
{ id: TABS.MYSHOWS, label: "My Shows" },
|
||||||
{ id: "discover", label: "Discover" },
|
{ id: TABS.DISCOVER, label: "Discover" },
|
||||||
{ id: "search", label: "Search" },
|
{ id: TABS.SEARCH, label: "Search" },
|
||||||
{ id: "player", label: "Player" },
|
{ id: TABS.PLAYER, label: "Player" },
|
||||||
{ id: "settings", label: "Settings" },
|
{ id: TABS.SETTINGS, label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TabNavigation(props: TabNavigationProps) {
|
export function TabNavigation() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const { activeTab, setActiveTab, activeDepth } = useNavigation();
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box
|
||||||
|
border
|
||||||
|
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
|
||||||
|
backgroundColor={"transparent"}
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
width: 12,
|
||||||
|
height: TabsCount * 3 + 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<For each={tabs}>
|
<For each={tabs}>
|
||||||
{(tab) => (
|
{(tab) => (
|
||||||
<box
|
<SelectableBox
|
||||||
border
|
border
|
||||||
borderColor={theme.border}
|
height={3}
|
||||||
onMouseDown={() => props.onTabSelect(tab.id)}
|
selected={() => tab.id == activeTab()}
|
||||||
style={{
|
onMouseDown={() => setActiveTab(tab.id)}
|
||||||
padding: 1,
|
|
||||||
backgroundColor:
|
|
||||||
tab.id == props.activeTab ? theme.primary : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<text style={{ fg: theme.text }}>
|
<SelectableText
|
||||||
{tab.id == props.activeTab ? "[" : " "}
|
selected={() => tab.id == activeTab()}
|
||||||
|
primary
|
||||||
|
alignSelf="center"
|
||||||
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.id == props.activeTab ? "]" : " "}
|
</SelectableText>
|
||||||
</text>
|
</SelectableBox>
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TabId =
|
|
||||||
| "feed"
|
|
||||||
| "shows"
|
|
||||||
| "discover"
|
|
||||||
| "search"
|
|
||||||
| "player"
|
|
||||||
| "settings";
|
|
||||||
|
|
||||||
export type TabDefinition = {
|
export type TabDefinition = {
|
||||||
id: TabId;
|
id: TABS;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|||||||
20
src/config/keybind.jsonc
Normal file
20
src/config/keybind.jsonc
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"up": ["up", "k"],
|
||||||
|
"down": ["down", "j"],
|
||||||
|
"left": ["left", "h"],
|
||||||
|
"right": ["right", "l"],
|
||||||
|
"cycle": ["tab"], // this will cycle no matter the depth/orientation
|
||||||
|
"dive": ["return"],
|
||||||
|
"out": ["esc"],
|
||||||
|
"inverseModifier": ["shift"],
|
||||||
|
"leader": ":", // will not trigger while focused on input
|
||||||
|
"quit": ["<leader>q"],
|
||||||
|
"refresh": ["<leader>r"],
|
||||||
|
"audio-toggle": ["<leader>p"],
|
||||||
|
"audio-pause": [],
|
||||||
|
"audio-play": [],
|
||||||
|
"audio-next": ["<leader>n"],
|
||||||
|
"audio-prev": ["<leader>l"],
|
||||||
|
"audio-seek-forward": ["<leader>sf"],
|
||||||
|
"audio-seek-backward": ["<leader>sb"],
|
||||||
|
}
|
||||||
@@ -1,134 +1,136 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createSignal, onMount } from "solid-js";
|
||||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
import { createSimpleContext } from "./helper";
|
||||||
import { createStore } from "solid-js/store"
|
import {
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
copyKeybindsIfNeeded,
|
||||||
import { createSimpleContext } from "./helper"
|
loadKeybindsFromFile,
|
||||||
import { Keybind, DEFAULT_KEYBINDS, type KeybindsConfig } from "../utils/keybind"
|
saveKeybindsToFile,
|
||||||
|
} from "../utils/keybinds-persistence";
|
||||||
|
import { createStore } from "solid-js/store";
|
||||||
|
|
||||||
/**
|
export type KeybindsResolved = {
|
||||||
* Keybind context provider for managing keyboard shortcuts.
|
up: string[];
|
||||||
*
|
down: string[];
|
||||||
* Features:
|
left: string[];
|
||||||
* - Leader key support (like vim's leader key)
|
right: string[];
|
||||||
* - Configurable keybindings
|
cycle: string[]; // this will cycle no matter the depth/orientation
|
||||||
* - Key parsing and matching
|
dive: string[];
|
||||||
* - Display-friendly key representations
|
out: string[];
|
||||||
*/
|
inverseModifier: string;
|
||||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
leader: string; // will not trigger while focused on input
|
||||||
name: "Keybind",
|
quit: string[];
|
||||||
init: (props: { keybinds?: Partial<KeybindsConfig> }) => {
|
select: string[]; // for selecting/activating items
|
||||||
// Merge default keybinds with custom keybinds
|
"audio-toggle": string[];
|
||||||
const customKeybinds = props.keybinds ?? {}
|
"audio-pause": string[];
|
||||||
const mergedKeybinds = { ...DEFAULT_KEYBINDS, ...customKeybinds }
|
"audio-play": string[];
|
||||||
|
"audio-next": string[];
|
||||||
|
"audio-prev": string[];
|
||||||
|
"audio-seek-forward": string[];
|
||||||
|
"audio-seek-backward": string[];
|
||||||
|
};
|
||||||
|
|
||||||
const keybinds = createMemo(() => {
|
export enum KeybindAction {
|
||||||
const result: Record<string, Keybind.Info[]> = {}
|
UP,
|
||||||
for (const [key, value] of Object.entries(mergedKeybinds)) {
|
DOWN,
|
||||||
result[key] = Keybind.parse(value)
|
LEFT,
|
||||||
}
|
RIGHT,
|
||||||
return result
|
CYCLE,
|
||||||
})
|
DIVE,
|
||||||
|
OUT,
|
||||||
|
QUIT,
|
||||||
|
SELECT,
|
||||||
|
AUDIO_TOGGLE,
|
||||||
|
AUDIO_PAUSE,
|
||||||
|
AUDIO_PLAY,
|
||||||
|
AUDIO_NEXT,
|
||||||
|
AUDIO_PREV,
|
||||||
|
AUDIO_SEEK_F,
|
||||||
|
AUDIO_SEEK_B,
|
||||||
|
}
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
export const { use: useKeybinds, provider: KeybindProvider } =
|
||||||
leader: false,
|
createSimpleContext({
|
||||||
})
|
name: "Keybinds",
|
||||||
|
init: () => {
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
up: [],
|
||||||
|
down: [],
|
||||||
|
left: [],
|
||||||
|
right: [],
|
||||||
|
cycle: [],
|
||||||
|
dive: [],
|
||||||
|
out: [],
|
||||||
|
inverseModifier: "",
|
||||||
|
leader: "",
|
||||||
|
quit: [],
|
||||||
|
select: [],
|
||||||
|
refresh: [],
|
||||||
|
"audio-toggle": [],
|
||||||
|
"audio-pause": [],
|
||||||
|
"audio-play": [],
|
||||||
|
"audio-next": [],
|
||||||
|
"audio-prev": [],
|
||||||
|
"audio-seek-forward": [],
|
||||||
|
"audio-seek-backward": [],
|
||||||
|
} as KeybindsResolved);
|
||||||
|
const [ready, setReady] = createSignal(false);
|
||||||
|
|
||||||
const renderer = useRenderer()
|
async function load() {
|
||||||
|
await copyKeybindsIfNeeded();
|
||||||
let focus: Renderable | null = null
|
const keybinds = await loadKeybindsFromFile();
|
||||||
let timeout: NodeJS.Timeout | undefined
|
setStore(keybinds);
|
||||||
|
setReady(true);
|
||||||
function leader(active: boolean) {
|
|
||||||
if (active) {
|
|
||||||
setStore("leader", true)
|
|
||||||
focus = renderer.currentFocusedRenderable
|
|
||||||
focus?.blur()
|
|
||||||
if (timeout) clearTimeout(timeout)
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
if (!store.leader) return
|
|
||||||
leader(false)
|
|
||||||
if (!focus || focus.isDestroyed) return
|
|
||||||
focus.focus()
|
|
||||||
}, 2000) // Leader key timeout
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!active) {
|
async function save() {
|
||||||
if (focus && !renderer.currentFocusedRenderable) {
|
saveKeybindsToFile(store);
|
||||||
focus.focus()
|
}
|
||||||
|
|
||||||
|
function print(input: keyof KeybindsResolved): string {
|
||||||
|
const keys = store[input] || [];
|
||||||
|
return Array.isArray(keys) ? keys.join(", ") : keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function match(
|
||||||
|
keybind: keyof KeybindsResolved,
|
||||||
|
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
|
||||||
|
): boolean {
|
||||||
|
const keys = store[keybind];
|
||||||
|
if (!keys) return false;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (evt.name === key) return true;
|
||||||
}
|
}
|
||||||
setStore("leader", false)
|
return false;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle leader key
|
|
||||||
useKeyboard(async (evt) => {
|
|
||||||
// Don't intercept leader key when a text-editing renderable (input/textarea)
|
|
||||||
// has focus — let it handle text input (including space for the leader key).
|
|
||||||
const focused = renderer.currentFocusedRenderable
|
|
||||||
if (focused && "insertText" in focused) return
|
|
||||||
|
|
||||||
if (!store.leader && result.match("leader", evt)) {
|
|
||||||
leader(true)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.leader && evt.name) {
|
function isInverting(evt: {
|
||||||
setImmediate(() => {
|
name: string;
|
||||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
ctrl?: boolean;
|
||||||
focus.focus()
|
meta?: boolean;
|
||||||
}
|
shift?: boolean;
|
||||||
leader(false)
|
}) {
|
||||||
})
|
if (store.inverseModifier === "ctrl" && evt.ctrl) return true;
|
||||||
|
if (store.inverseModifier === "meta" && evt.meta) return true;
|
||||||
|
if (store.inverseModifier === "shift" && evt.shift) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const result = {
|
// Load on mount
|
||||||
get all() {
|
onMount(() => {
|
||||||
return keybinds()
|
load().catch(() => {});
|
||||||
},
|
});
|
||||||
get leader() {
|
|
||||||
return store.leader
|
return {
|
||||||
},
|
get ready() {
|
||||||
/**
|
return ready();
|
||||||
* Parse a keyboard event into a Keybind.Info.
|
},
|
||||||
*/
|
get keybinds() {
|
||||||
parse(evt: ParsedKey): Keybind.Info {
|
return store;
|
||||||
// Handle special case for Ctrl+Underscore (represented as \x1F)
|
},
|
||||||
if (evt.name === "\x1F") {
|
save,
|
||||||
return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
|
print,
|
||||||
}
|
match,
|
||||||
return Keybind.fromParsedKey(evt, store.leader)
|
isInverting,
|
||||||
},
|
};
|
||||||
/**
|
},
|
||||||
* Check if a keyboard event matches a registered keybind.
|
});
|
||||||
*/
|
|
||||||
match(key: keyof KeybindsConfig, evt: ParsedKey): boolean {
|
|
||||||
const keybind = keybinds()[key]
|
|
||||||
if (!keybind) return false
|
|
||||||
const parsed: Keybind.Info = result.parse(evt)
|
|
||||||
for (const kb of keybind) {
|
|
||||||
if (Keybind.match(kb, parsed)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Get a display string for a registered keybind.
|
|
||||||
*/
|
|
||||||
print(key: keyof KeybindsConfig): string {
|
|
||||||
const first = keybinds()[key]?.at(0)
|
|
||||||
if (!first) return ""
|
|
||||||
const display = Keybind.toString(first)
|
|
||||||
// Replace leader placeholder with actual leader key
|
|
||||||
const leaderKey = keybinds().leader?.[0]
|
|
||||||
if (leaderKey) {
|
|
||||||
return display.replace("<leader>", Keybind.toString(leaderKey))
|
|
||||||
}
|
|
||||||
return display
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
73
src/context/NavigationContext.tsx
Normal file
73
src/context/NavigationContext.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { createEffect, createSignal, on } from "solid-js";
|
||||||
|
import { createSimpleContext } from "./helper";
|
||||||
|
import { TABS, TabsCount, LayerDepths } from "@/utils/navigation";
|
||||||
|
|
||||||
|
// Page-specific pane counts
|
||||||
|
const PANE_COUNTS = {
|
||||||
|
[TABS.FEED]: 1,
|
||||||
|
[TABS.MYSHOWS]: 2,
|
||||||
|
[TABS.DISCOVER]: 2,
|
||||||
|
[TABS.SEARCH]: 3,
|
||||||
|
[TABS.PLAYER]: 1,
|
||||||
|
[TABS.SETTINGS]: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const { use: useNavigation, provider: NavigationProvider } =
|
||||||
|
createSimpleContext({
|
||||||
|
name: "Navigation",
|
||||||
|
init: () => {
|
||||||
|
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
||||||
|
const [activeDepth, setActiveDepth] = createSignal(0);
|
||||||
|
const [inputFocused, setInputFocused] = createSignal(false);
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => activeTab,
|
||||||
|
() => setActiveDepth(0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextTab = () => {
|
||||||
|
if (activeTab() >= TabsCount) {
|
||||||
|
setActiveTab(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(activeTab() + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevTab = () => {
|
||||||
|
if (activeTab() <= 1) {
|
||||||
|
setActiveTab(TabsCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(activeTab() - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPane = () => {
|
||||||
|
// Move to next pane within the current tab's pane structure
|
||||||
|
const count = PANE_COUNTS[activeTab()];
|
||||||
|
if (count <= 1) return; // No panes to navigate (feed/player)
|
||||||
|
setActiveDepth((prev) => (prev % count) + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevPane = () => {
|
||||||
|
// Move to previous pane within the current tab's pane structure
|
||||||
|
const count = PANE_COUNTS[activeTab()];
|
||||||
|
if (count <= 1) return; // No panes to navigate (feed/player)
|
||||||
|
setActiveDepth((prev) => (prev - 2 + count) % count + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
activeDepth,
|
||||||
|
inputFocused,
|
||||||
|
setActiveTab,
|
||||||
|
setActiveDepth,
|
||||||
|
setInputFocused,
|
||||||
|
nextTab,
|
||||||
|
prevTab,
|
||||||
|
nextPane,
|
||||||
|
prevPane,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test"
|
|
||||||
import { ThemeProvider } from "./ThemeContext"
|
|
||||||
|
|
||||||
describe("ThemeContext", () => {
|
|
||||||
it("exports provider", () => {
|
|
||||||
expect(typeof ThemeProvider).toBe("function")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type TerminalColors,
|
type TerminalColors,
|
||||||
} from "@opentui/core";
|
} from "@opentui/core";
|
||||||
|
|
||||||
type ThemeResolved = {
|
export type ThemeResolved = {
|
||||||
primary: RGBA;
|
primary: RGBA;
|
||||||
secondary: RGBA;
|
secondary: RGBA;
|
||||||
accent: RGBA;
|
accent: RGBA;
|
||||||
@@ -32,7 +32,13 @@ type ThemeResolved = {
|
|||||||
info: RGBA;
|
info: RGBA;
|
||||||
text: RGBA;
|
text: RGBA;
|
||||||
textMuted: RGBA;
|
textMuted: RGBA;
|
||||||
selectedListItemText: RGBA;
|
textPrimary: RGBA;
|
||||||
|
textSecondary: RGBA;
|
||||||
|
textTertiary: RGBA;
|
||||||
|
textSelectedPrimary: RGBA;
|
||||||
|
textSelectedSecondary: RGBA;
|
||||||
|
textSelectedTertiary: RGBA;
|
||||||
|
|
||||||
background: RGBA;
|
background: RGBA;
|
||||||
backgroundPanel: RGBA;
|
backgroundPanel: RGBA;
|
||||||
backgroundElement: RGBA;
|
backgroundElement: RGBA;
|
||||||
@@ -77,6 +83,7 @@ type ThemeResolved = {
|
|||||||
syntaxPunctuation: RGBA;
|
syntaxPunctuation: RGBA;
|
||||||
muted?: RGBA;
|
muted?: RGBA;
|
||||||
surface?: RGBA;
|
surface?: RGBA;
|
||||||
|
selectedListItemText?: RGBA;
|
||||||
layerBackgrounds?: {
|
layerBackgrounds?: {
|
||||||
layer0: RGBA;
|
layer0: RGBA;
|
||||||
layer1: RGBA;
|
layer1: RGBA;
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* Centralized keyboard shortcuts hook for PodTUI
|
|
||||||
* Single handler to prevent conflicts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
|
||||||
import type { TabId } from "../components/Tab"
|
|
||||||
import type { Accessor } from "solid-js"
|
|
||||||
|
|
||||||
const TAB_ORDER: TabId[] = ["feed", "shows", "discover", "search", "player", "settings"]
|
|
||||||
|
|
||||||
type ShortcutOptions = {
|
|
||||||
activeTab: TabId
|
|
||||||
onTabChange: (tab: TabId) => void
|
|
||||||
onAction?: (action: string) => void
|
|
||||||
inputFocused?: boolean
|
|
||||||
navigationEnabled?: boolean
|
|
||||||
layerDepth?: Accessor<number>
|
|
||||||
onLayerChange?: (newDepth: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "return") {
|
|
||||||
options.onAction?.("enter")
|
|
||||||
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") {
|
|
||||||
if (key.shift) {
|
|
||||||
options.onTabChange(getPrevTab(options.activeTab))
|
|
||||||
} else {
|
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,9 @@ import { useAppStore } from "../stores/app"
|
|||||||
import { useProgressStore } from "../stores/progress"
|
import { useProgressStore } from "../stores/progress"
|
||||||
import { useMediaRegistry } from "../utils/media-registry"
|
import { useMediaRegistry } from "../utils/media-registry"
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"
|
||||||
|
import { useFeedStore } from "../stores/feed"
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
// Signals (reactive getters)
|
// Signals (reactive getters)
|
||||||
@@ -49,6 +52,8 @@ export interface AudioControls {
|
|||||||
setVolume: (volume: number) => Promise<void>
|
setVolume: (volume: number) => Promise<void>
|
||||||
setSpeed: (speed: number) => Promise<void>
|
setSpeed: (speed: number) => Promise<void>
|
||||||
switchBackend: (name: BackendName) => Promise<void>
|
switchBackend: (name: BackendName) => Promise<void>
|
||||||
|
prev: () => Promise<void>
|
||||||
|
next: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton state — shared across all components that call useAudio()
|
// Singleton state — shared across all components that call useAudio()
|
||||||
@@ -401,6 +406,76 @@ export function useAudio(): AudioControls {
|
|||||||
await doSetSpeed(next)
|
await doSetSpeed(next)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
|
async function prev(): Promise<void> {
|
||||||
|
const current = currentEpisode();
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
const currentPos = position();
|
||||||
|
const currentDur = duration();
|
||||||
|
|
||||||
|
const NAV_START_THRESHOLD = 30;
|
||||||
|
|
||||||
|
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||||
|
await seek(NAV_START_THRESHOLD);
|
||||||
|
} else {
|
||||||
|
const source = audioNav.getSource();
|
||||||
|
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||||
|
|
||||||
|
if (source === AudioSource.FEED) {
|
||||||
|
episodes = feedStore.getAllEpisodesChronological();
|
||||||
|
} else if (source === AudioSource.MY_SHOWS) {
|
||||||
|
const podcastId = audioNav.getPodcastId();
|
||||||
|
if (!podcastId) return;
|
||||||
|
|
||||||
|
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
||||||
|
if (!feed) return;
|
||||||
|
|
||||||
|
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = audioNav.getCurrentIndex();
|
||||||
|
const newIndex = Math.max(0, currentIndex - 1);
|
||||||
|
|
||||||
|
if (newIndex < episodes.length && episodes[newIndex]) {
|
||||||
|
const { episode } = episodes[newIndex];
|
||||||
|
await play(episode);
|
||||||
|
audioNav.prev(newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next(): Promise<void> {
|
||||||
|
const current = currentEpisode();
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
const source = audioNav.getSource();
|
||||||
|
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||||
|
|
||||||
|
if (source === AudioSource.FEED) {
|
||||||
|
episodes = feedStore.getAllEpisodesChronological();
|
||||||
|
} else if (source === AudioSource.MY_SHOWS) {
|
||||||
|
const podcastId = audioNav.getPodcastId();
|
||||||
|
if (!podcastId) return;
|
||||||
|
|
||||||
|
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
||||||
|
if (!feed) return;
|
||||||
|
|
||||||
|
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = audioNav.getCurrentIndex();
|
||||||
|
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
|
||||||
|
|
||||||
|
if (newIndex >= 0 && episodes[newIndex]) {
|
||||||
|
const { episode } = episodes[newIndex];
|
||||||
|
await play(episode);
|
||||||
|
audioNav.next(newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
refCount--
|
refCount--
|
||||||
unsubPlay()
|
unsubPlay()
|
||||||
@@ -447,5 +522,7 @@ export function useAudio(): AudioControls {
|
|||||||
setVolume: doSetVolume,
|
setVolume: doSetVolume,
|
||||||
setSpeed: doSetSpeed,
|
setSpeed: doSetSpeed,
|
||||||
switchBackend,
|
switchBackend,
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
|
||||||
|
|
||||||
type ShortcutOptions = {
|
|
||||||
onSave?: () => void
|
|
||||||
onQuit?: () => void
|
|
||||||
onTabNext?: () => void
|
|
||||||
onTabPrev?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcuts(options: ShortcutOptions) {
|
|
||||||
const renderer = useRenderer()
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (key.ctrl && key.name === "q") {
|
|
||||||
if (options.onQuit) {
|
|
||||||
options.onQuit()
|
|
||||||
} else {
|
|
||||||
renderer.destroy()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.ctrl && key.name === "s") {
|
|
||||||
options.onSave?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "right") {
|
|
||||||
options.onTabNext?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "left") {
|
|
||||||
options.onTabPrev?.()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
258
src/index.tsx
258
src/index.tsx
@@ -1,39 +1,225 @@
|
|||||||
// Hack: Force TERM to tmux-256color when running in tmux to enable
|
const VERSION = "0.1.0";
|
||||||
// correct palette detection in @opentui/core
|
|
||||||
//if (process.env.TMUX && !process.env.TERM?.includes("tmux")) {
|
|
||||||
//process.env.TERM = "tmux-256color"
|
|
||||||
//}
|
|
||||||
|
|
||||||
import { render, useRenderer } from "@opentui/solid";
|
interface CliArgs {
|
||||||
import { App } from "./App";
|
version: boolean;
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
query: string | null;
|
||||||
import { ToastProvider, Toast } from "./ui/toast";
|
play: string | null;
|
||||||
import { KeybindProvider } from "./context/KeybindContext";
|
|
||||||
import { DialogProvider } from "./ui/dialog";
|
|
||||||
import { CommandProvider } from "./ui/command";
|
|
||||||
|
|
||||||
function RendererSetup(props: { children: unknown }) {
|
|
||||||
const renderer = useRenderer();
|
|
||||||
renderer.disableStdoutInterception();
|
|
||||||
return props.children;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
function parseArgs(): CliArgs {
|
||||||
() => (
|
const args = process.argv.slice(2);
|
||||||
<RendererSetup>
|
const result: CliArgs = {
|
||||||
<ToastProvider>
|
version: false,
|
||||||
<ThemeProvider mode="dark">
|
query: null,
|
||||||
<KeybindProvider>
|
play: null,
|
||||||
<DialogProvider>
|
};
|
||||||
<CommandProvider>
|
|
||||||
<App />
|
for (let i = 0; i < args.length; i++) {
|
||||||
<Toast />
|
const arg = args[i];
|
||||||
</CommandProvider>
|
if (arg === "--version" || arg === "-v") {
|
||||||
</DialogProvider>
|
result.version = true;
|
||||||
</KeybindProvider>
|
} else if (arg === "--query" || arg === "-q") {
|
||||||
</ThemeProvider>
|
result.query = args[i + 1] || "";
|
||||||
</ToastProvider>
|
i++;
|
||||||
</RendererSetup>
|
} else if (arg === "--play" || arg === "-p") {
|
||||||
),
|
result.play = args[i + 1] || "";
|
||||||
{ useThread: false },
|
i++;
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliArgs = parseArgs();
|
||||||
|
|
||||||
|
if (cliArgs.version) {
|
||||||
|
console.log(`PodTUI version ${VERSION}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||||
|
import("./utils/feeds-persistence").then(async ({ loadFeedsFromFile }) => {
|
||||||
|
const feeds = await loadFeedsFromFile();
|
||||||
|
|
||||||
|
if (cliArgs.query !== null) {
|
||||||
|
const query = cliArgs.query;
|
||||||
|
const normalizedQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
const matches = feeds.filter((feed) => {
|
||||||
|
const title = feed.podcast.title.toLowerCase();
|
||||||
|
return title.includes(normalizedQuery);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
console.log(`No shows found matching: ${query}`);
|
||||||
|
if (feeds.length > 0) {
|
||||||
|
console.log("\nAvailable shows:");
|
||||||
|
feeds.slice(0, 5).forEach((feed) => {
|
||||||
|
console.log(` - ${feed.podcast.title}`);
|
||||||
|
});
|
||||||
|
if (feeds.length > 5) {
|
||||||
|
console.log(` ... and ${feeds.length - 5} more`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const feed = matches[0];
|
||||||
|
console.log(`\n${feed.podcast.title}`);
|
||||||
|
if (feed.podcast.description) {
|
||||||
|
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
|
||||||
|
}
|
||||||
|
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
|
||||||
|
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
||||||
|
});
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nClosest matches for "${query}":`);
|
||||||
|
matches.slice(0, 5).forEach((feed, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
||||||
|
});
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliArgs.play !== null) {
|
||||||
|
const playArg = cliArgs.play;
|
||||||
|
const normalizedArg = playArg.toLowerCase();
|
||||||
|
|
||||||
|
let feedResult: typeof feeds[0] | null = null;
|
||||||
|
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
|
||||||
|
|
||||||
|
if (normalizedArg === "latest") {
|
||||||
|
let latestFeed: typeof feeds[0] | null = null;
|
||||||
|
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
|
||||||
|
let latestDate = 0;
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
if (feed.episodes.length > 0) {
|
||||||
|
const ep = feed.episodes[0];
|
||||||
|
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
|
||||||
|
if (epDate > latestDate) {
|
||||||
|
latestDate = epDate;
|
||||||
|
latestFeed = feed;
|
||||||
|
latestEpisode = ep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedResult = latestFeed;
|
||||||
|
episodeResult = latestEpisode;
|
||||||
|
} else {
|
||||||
|
const parts = normalizedArg.split("/");
|
||||||
|
const showQuery = parts[0];
|
||||||
|
const episodeQuery = parts[1];
|
||||||
|
|
||||||
|
const matchingFeeds = feeds.filter((feed) =>
|
||||||
|
feed.podcast.title.toLowerCase().includes(showQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingFeeds.length === 0) {
|
||||||
|
console.log(`No show found matching: ${showQuery}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = matchingFeeds[0];
|
||||||
|
|
||||||
|
if (!episodeQuery) {
|
||||||
|
if (feed.episodes.length > 0) {
|
||||||
|
feedResult = feed;
|
||||||
|
episodeResult = feed.episodes[0];
|
||||||
|
} else {
|
||||||
|
console.log(`No episodes available for: ${feed.podcast.title}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else if (episodeQuery === "latest") {
|
||||||
|
feedResult = feed;
|
||||||
|
episodeResult = feed.episodes[0];
|
||||||
|
} else {
|
||||||
|
const matchingEpisode = feed.episodes.find((ep) =>
|
||||||
|
ep.title.toLowerCase().includes(episodeQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingEpisode) {
|
||||||
|
feedResult = feed;
|
||||||
|
episodeResult = matchingEpisode;
|
||||||
|
} else {
|
||||||
|
console.log(`Episode not found: ${episodeQuery}`);
|
||||||
|
console.log(`Available episodes for ${feed.podcast.title}:`);
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${ep.title}`);
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedResult || !episodeResult) {
|
||||||
|
console.log("Could not find episode to play");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nPlaying: ${episodeResult.title}`);
|
||||||
|
console.log(`Show: ${feedResult.podcast.title}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createAudioBackend } = await import("./utils/audio-player");
|
||||||
|
const backend = createAudioBackend();
|
||||||
|
if (episodeResult.audioUrl) {
|
||||||
|
await backend.play(episodeResult.audioUrl);
|
||||||
|
console.log("Playback started (use the UI to control)");
|
||||||
|
} else {
|
||||||
|
console.log("No audio URL available for this episode");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Playback error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("Error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
||||||
|
const { App } = await import("./App");
|
||||||
|
const { ThemeProvider } = await import("./context/ThemeContext");
|
||||||
|
const toast = await import("./ui/toast");
|
||||||
|
const { KeybindProvider } = await import("./context/KeybindContext");
|
||||||
|
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||||
|
const { DialogProvider } = await import("./ui/dialog");
|
||||||
|
const { CommandProvider } = await import("./ui/command");
|
||||||
|
|
||||||
|
function RendererSetup(props: { children: unknown }) {
|
||||||
|
const renderer = useRenderer();
|
||||||
|
renderer.disableStdoutInterception();
|
||||||
|
return props.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
() => (
|
||||||
|
<RendererSetup>
|
||||||
|
<toast.ToastProvider>
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<KeybindProvider>
|
||||||
|
<NavigationProvider>
|
||||||
|
<DialogProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<App />
|
||||||
|
<toast.Toast />
|
||||||
|
</CommandProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
</KeybindProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</toast.ToastProvider>
|
||||||
|
</RendererSetup>
|
||||||
|
),
|
||||||
|
{ useThread: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
185
src/pages/Discover/DiscoverPage.tsx
Normal file
185
src/pages/Discover/DiscoverPage.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* DiscoverPage component - Main discover/browse interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For, Show, onMount } from "solid-js";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PodcastCard } from "./PodcastCard";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
||||||
|
|
||||||
|
enum DiscoverPagePaneType {
|
||||||
|
CATEGORIES = 1,
|
||||||
|
SHOWS = 2,
|
||||||
|
}
|
||||||
|
export const DiscoverPaneCount = 2;
|
||||||
|
|
||||||
|
export function DiscoverPage() {
|
||||||
|
const discoverStore = useDiscoverStore();
|
||||||
|
const [showIndex, setShowIndex] = createSignal(0);
|
||||||
|
const [categoryIndex, setCategoryIndex] = createSignal(0);
|
||||||
|
const nav = useNavigation();
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
useKeyboard(
|
||||||
|
(keyEvent: any) => {
|
||||||
|
const isDown = keybind.match("down", keyEvent);
|
||||||
|
const isUp = keybind.match("up", keyEvent);
|
||||||
|
const isCycle = keybind.match("cycle", keyEvent);
|
||||||
|
const isSelect = keybind.match("select", keyEvent);
|
||||||
|
const isInverting = keybind.isInverting(keyEvent);
|
||||||
|
|
||||||
|
if (isSelect) {
|
||||||
|
const filteredPodcasts = discoverStore.filteredPodcasts();
|
||||||
|
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
|
||||||
|
setShowIndex(showIndex() + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't handle pane navigation here - unified in App.tsx
|
||||||
|
if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return;
|
||||||
|
|
||||||
|
const filteredPodcasts = discoverStore.filteredPodcasts();
|
||||||
|
if (filteredPodcasts.length === 0) return;
|
||||||
|
|
||||||
|
if (isDown && !isInverting()) {
|
||||||
|
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
|
||||||
|
} else if (isUp && isInverting()) {
|
||||||
|
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
|
||||||
|
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
|
||||||
|
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
|
||||||
|
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
|
||||||
|
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ release: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubscribe = (podcast: { id: string }) => {
|
||||||
|
discoverStore.toggleSubscription(podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
borderColor={
|
||||||
|
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
|
||||||
|
? theme.border
|
||||||
|
: theme.accent
|
||||||
|
}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
nav.activeDepth() == DiscoverPagePaneType.CATEGORIES
|
||||||
|
? theme.accent
|
||||||
|
: theme.text
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Categories:
|
||||||
|
</text>
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<For each={discoverStore.categories}>
|
||||||
|
{(category) => {
|
||||||
|
const isSelected = () =>
|
||||||
|
discoverStore.selectedCategory() === category.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectableBox
|
||||||
|
selected={isSelected}
|
||||||
|
onMouseDown={() => handleCategorySelect(category.id)}
|
||||||
|
>
|
||||||
|
<SelectableText selected={isSelected} primary>
|
||||||
|
{category.icon} {category.name}
|
||||||
|
</SelectableText>
|
||||||
|
</SelectableBox>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={1}
|
||||||
|
border
|
||||||
|
borderColor={
|
||||||
|
nav.activeDepth() == DiscoverPagePaneType.SHOWS
|
||||||
|
? theme.accent
|
||||||
|
: theme.border
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => false}
|
||||||
|
primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
|
||||||
|
>
|
||||||
|
Trending in{" "}
|
||||||
|
{DISCOVER_CATEGORIES.find(
|
||||||
|
(c) => c.id === discoverStore.selectedCategory(),
|
||||||
|
)?.name ?? "All"}
|
||||||
|
</SelectableText>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="column" height="100%">
|
||||||
|
<Show
|
||||||
|
fallback={
|
||||||
|
<box padding={2}>
|
||||||
|
{discoverStore.filteredPodcasts().length !== 0 ? (
|
||||||
|
<text fg={theme.warning}>Loading trending shows...</text>
|
||||||
|
) : (
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
No podcasts found in this category.
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
when={
|
||||||
|
!discoverStore.isLoading() &&
|
||||||
|
discoverStore.filteredPodcasts().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox
|
||||||
|
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
|
||||||
|
>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={discoverStore.filteredPodcasts()}>
|
||||||
|
{(podcast, index) => (
|
||||||
|
<PodcastCard
|
||||||
|
podcast={podcast}
|
||||||
|
selected={
|
||||||
|
index() === showIndex() &&
|
||||||
|
nav.activeDepth() == DiscoverPagePaneType.SHOWS
|
||||||
|
}
|
||||||
|
onSelect={() => handleShowSelect(index())}
|
||||||
|
onSubscribe={() => handleSubscribe(podcast)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
import { Show, For } from "solid-js";
|
import { Show, For } from "solid-js";
|
||||||
import type { Podcast } from "@/types/podcast";
|
import type { Podcast } from "@/types/podcast";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
|
||||||
type PodcastCardProps = {
|
type PodcastCardProps = {
|
||||||
podcast: Podcast;
|
podcast: Podcast;
|
||||||
@@ -14,64 +16,70 @@ type PodcastCardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function PodcastCard(props: PodcastCardProps) {
|
export function PodcastCard(props: PodcastCardProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const handleSubscribeClick = () => {
|
const handleSubscribeClick = () => {
|
||||||
props.onSubscribe?.();
|
props.onSubscribe?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<SelectableBox
|
||||||
|
selected={() => props.selected}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={props.selected ? "#333" : undefined}
|
|
||||||
onMouseDown={props.onSelect}
|
onMouseDown={props.onSelect}
|
||||||
>
|
>
|
||||||
{/* Title Row */}
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
<box flexDirection="row" gap={2} alignItems="center">
|
||||||
<text fg={props.selected ? "cyan" : "white"}>
|
<SelectableText selected={() => props.selected} primary>
|
||||||
<strong>{props.podcast.title}</strong>
|
<strong>{props.podcast.title}</strong>
|
||||||
</text>
|
</SelectableText>
|
||||||
|
|
||||||
<Show when={props.podcast.isSubscribed}>
|
<Show when={props.podcast.isSubscribed}>
|
||||||
<text fg="green">[+]</text>
|
<text fg={theme.success}>[+]</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Author */}
|
{/* Author */}
|
||||||
<Show when={props.podcast.author && !props.compact}>
|
<Show when={props.podcast.author && !props.compact}>
|
||||||
<text fg="gray">by {props.podcast.author}</text>
|
<SelectableText
|
||||||
|
selected={() => props.selected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
by {props.podcast.author}
|
||||||
|
</SelectableText>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<Show when={props.podcast.description && !props.compact}>
|
<Show when={props.podcast.description && !props.compact}>
|
||||||
<text fg={props.selected ? "white" : "gray"}>
|
<SelectableText
|
||||||
|
selected={() => props.selected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
{props.podcast.description!.length > 80
|
{props.podcast.description!.length > 80
|
||||||
? props.podcast.description!.slice(0, 80) + "..."
|
? props.podcast.description!.slice(0, 80) + "..."
|
||||||
: props.podcast.description}
|
: props.podcast.description}
|
||||||
</text>
|
</SelectableText>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Categories and Subscribe Button */}
|
{/**<box
|
||||||
<box
|
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
marginTop={props.compact ? 0 : 1}
|
marginTop={props.compact ? 0 : 1}
|
||||||
>
|
/>**/}
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<Show when={(props.podcast.categories ?? []).length > 0}>
|
<Show when={(props.podcast.categories ?? []).length > 0}>
|
||||||
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
|
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
|
||||||
{(cat) => <text fg="yellow">[{cat}]</text>}
|
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show when={props.selected}>
|
|
||||||
<box onMouseDown={handleSubscribeClick}>
|
|
||||||
<text fg={props.podcast.isSubscribed ? "red" : "green"}>
|
|
||||||
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
|
||||||
|
<Show when={props.selected}>
|
||||||
|
<box onMouseDown={handleSubscribeClick}>
|
||||||
|
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
|
||||||
|
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</SelectableBox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ import { useKeyboard } from "@opentui/solid";
|
|||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
|
||||||
interface FeedDetailProps {
|
interface FeedDetailProps {
|
||||||
feed: Feed;
|
feed: Feed;
|
||||||
@@ -17,6 +19,7 @@ interface FeedDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedDetail(props: FeedDetailProps) {
|
export function FeedDetail(props: FeedDetailProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||||
const [showInfo, setShowInfo] = createSignal(true);
|
const [showInfo, setShowInfo] = createSignal(true);
|
||||||
|
|
||||||
@@ -53,11 +56,16 @@ export function FeedDetail(props: FeedDetailProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key.name === "v") {
|
||||||
|
props.feed.podcast.onToggleVisibility?.(props.feed.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
if (key.name === "up" || key.name === "k") {
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||||
} else if (key.name === "down" || key.name === "j") {
|
} else if (key.name === "down" || key.name === "j") {
|
||||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
|
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
} else if (key.name === "return") {
|
||||||
const episode = eps[selectedIndex()];
|
const episode = eps[selectedIndex()];
|
||||||
if (episode && props.onPlayEpisode) {
|
if (episode && props.onPlayEpisode) {
|
||||||
props.onPlayEpisode(episode);
|
props.onPlayEpisode(episode);
|
||||||
@@ -82,66 +90,72 @@ export function FeedDetail(props: FeedDetailProps) {
|
|||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
{/* Header with back button */}
|
{/* Header with back button */}
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<box border padding={0} onMouseDown={props.onBack}>
|
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
|
||||||
<text fg="cyan">[Esc] Back</text>
|
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)}>
|
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
|
||||||
<text fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</text>
|
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
|
||||||
|
</box>
|
||||||
|
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
|
||||||
|
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Podcast info section */}
|
{/* Podcast info section */}
|
||||||
<Show when={showInfo()}>
|
<Show when={showInfo()}>
|
||||||
<box border padding={1} flexDirection="column" gap={0}>
|
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
|
||||||
<text>
|
<SelectableText selected={() => false} primary>
|
||||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||||
</text>
|
</SelectableText>
|
||||||
{props.feed.podcast.author && (
|
{props.feed.podcast.author && (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg="gray">by</text>
|
<SelectableText selected={() => false} tertiary>by</SelectableText>
|
||||||
<text fg="cyan">{props.feed.podcast.author}</text>
|
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg="gray">
|
<SelectableText selected={() => false} tertiary>
|
||||||
{props.feed.podcast.description?.slice(0, 200)}
|
{props.feed.podcast.description?.slice(0, 200)}
|
||||||
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
||||||
</text>
|
</SelectableText>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<box flexDirection="row" gap={2}>
|
<box flexDirection="row" gap={2}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg="gray">Episodes:</text>
|
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
|
||||||
<text fg="white">{props.feed.episodes.length}</text>
|
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg="gray">Updated:</text>
|
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
|
||||||
<text fg="white">{formatDate(props.feed.lastUpdated)}</text>
|
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
<SelectableText selected={() => false} tertiary>
|
||||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||||
</text>
|
</SelectableText>
|
||||||
{props.feed.isPinned && <text fg="yellow">[Pinned]</text>}
|
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Episodes header */}
|
{/* Episodes header */}
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text>
|
<SelectableText selected={() => false} primary>
|
||||||
<strong>Episodes</strong>
|
<strong>Episodes</strong>
|
||||||
</text>
|
</SelectableText>
|
||||||
<text fg="gray">({episodes().length} total)</text>
|
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Episode list */}
|
{/* Episode list */}
|
||||||
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(episode, index) => (
|
{(episode, index) => (
|
||||||
<box
|
<SelectableBox
|
||||||
|
selected={() => index() === selectedIndex()}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
setSelectedIndex(index());
|
setSelectedIndex(index());
|
||||||
if (props.onPlayEpisode) {
|
if (props.onPlayEpisode) {
|
||||||
@@ -149,26 +163,30 @@ export function FeedDetail(props: FeedDetailProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<SelectableText
|
||||||
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
selected={() => index() === selectedIndex()}
|
||||||
{index() === selectedIndex() ? ">" : " "}
|
primary
|
||||||
</text>
|
>
|
||||||
<text fg={index() === selectedIndex() ? "white" : undefined}>
|
{index() === selectedIndex() ? ">" : " "}
|
||||||
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
</SelectableText>
|
||||||
{episode.title}
|
<SelectableText
|
||||||
</text>
|
selected={() => index() === selectedIndex()}
|
||||||
</box>
|
primary
|
||||||
|
>
|
||||||
|
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
||||||
|
{episode.title}
|
||||||
|
</SelectableText>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
|
||||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</SelectableBox>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
|
|
||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<text fg="gray">
|
<text fg={theme.textMuted}>
|
||||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||||
import type { FeedFilter } from "@/types/feed";
|
import type { FeedFilter } from "@/types/feed";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
interface FeedFilterProps {
|
interface FeedFilterProps {
|
||||||
filter: FeedFilter;
|
filter: FeedFilter;
|
||||||
@@ -13,15 +14,16 @@ interface FeedFilterProps {
|
|||||||
onFilterChange: (filter: FeedFilter) => void;
|
onFilterChange: (filter: FeedFilter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterField = "visibility" | "sort" | "pinned" | "search";
|
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
|
||||||
|
|
||||||
export function FeedFilterComponent(props: FeedFilterProps) {
|
export function FeedFilterComponent(props: FeedFilterProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
|
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
|
||||||
const [searchValue, setSearchValue] = createSignal(
|
const [searchValue, setSearchValue] = createSignal(
|
||||||
props.filter.searchQuery || "",
|
props.filter.searchQuery || "",
|
||||||
);
|
);
|
||||||
|
|
||||||
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"];
|
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
if (key.name === "tab") {
|
if (key.name === "tab") {
|
||||||
@@ -30,17 +32,21 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
? (currentIndex - 1 + fields.length) % fields.length
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
: (currentIndex + 1) % fields.length;
|
: (currentIndex + 1) % fields.length;
|
||||||
setFocusField(fields[nextIndex]);
|
setFocusField(fields[nextIndex]);
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
} else if (key.name === "return") {
|
||||||
if (focusField() === "visibility") {
|
if (focusField() === "visibility") {
|
||||||
cycleVisibility();
|
cycleVisibility();
|
||||||
} else if (focusField() === "sort") {
|
} else if (focusField() === "sort") {
|
||||||
cycleSort();
|
cycleSort();
|
||||||
} else if (focusField() === "pinned") {
|
} else if (focusField() === "pinned") {
|
||||||
togglePinned();
|
togglePinned();
|
||||||
|
} else if (focusField() === "private") {
|
||||||
|
togglePrivate();
|
||||||
}
|
}
|
||||||
} else if (key.name === "space") {
|
} else if (key.name === "space") {
|
||||||
if (focusField() === "pinned") {
|
if (focusField() === "pinned") {
|
||||||
togglePinned();
|
togglePinned();
|
||||||
|
} else if (focusField() === "private") {
|
||||||
|
togglePrivate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,6 +81,13 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePrivate = () => {
|
||||||
|
props.onFilterChange({
|
||||||
|
...props.filter,
|
||||||
|
showPrivate: !props.filter.showPrivate,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchInput = (value: string) => {
|
const handleSearchInput = (value: string) => {
|
||||||
setSearchValue(value);
|
setSearchValue(value);
|
||||||
props.onFilterChange({ ...props.filter, searchQuery: value });
|
props.onFilterChange({ ...props.filter, searchQuery: value });
|
||||||
@@ -89,9 +102,9 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
|
|
||||||
const visibilityColor = () => {
|
const visibilityColor = () => {
|
||||||
const vis = props.filter.visibility;
|
const vis = props.filter.visibility;
|
||||||
if (vis === "public") return "green";
|
if (vis === "public") return theme.success;
|
||||||
if (vis === "private") return "yellow";
|
if (vis === "private") return theme.warning;
|
||||||
return "white";
|
return theme.text;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortLabel = () => {
|
const sortLabel = () => {
|
||||||
@@ -110,8 +123,8 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border padding={1} gap={1}>
|
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<strong>Filter Feeds</strong>
|
<strong>Filter Feeds</strong>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
@@ -120,10 +133,11 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={0}
|
padding={0}
|
||||||
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
|
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
|
||||||
|
borderColor={theme.border}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>
|
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
|
||||||
Show:
|
Show:
|
||||||
</text>
|
</text>
|
||||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
||||||
@@ -134,11 +148,11 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={0}
|
padding={0}
|
||||||
backgroundColor={focusField() === "sort" ? "#333" : undefined}
|
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusField() === "sort" ? "cyan" : "gray"}>Sort:</text>
|
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
|
||||||
<text fg="white">{sortLabel()}</text>
|
<text fg={theme.text}>{sortLabel()}</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
@@ -146,22 +160,38 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={0}
|
padding={0}
|
||||||
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
|
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>
|
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
|
||||||
Pinned:
|
Pinned:
|
||||||
</text>
|
</text>
|
||||||
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
|
||||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
|
{/* Private filter */}
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
|
||||||
|
Private:
|
||||||
|
</text>
|
||||||
|
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
|
||||||
|
{props.filter.showPrivate ? "Yes" : "No"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Search box */}
|
{/* Search box */}
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusField() === "search" ? "cyan" : "gray"}>Search:</text>
|
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
|
||||||
<input
|
<input
|
||||||
value={searchValue()}
|
value={searchValue()}
|
||||||
onInput={handleSearchInput}
|
onInput={handleSearchInput}
|
||||||
@@ -171,7 +201,7 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
|||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
|
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
154
src/pages/Feed/FeedItem.tsx
Normal file
154
src/pages/Feed/FeedItem.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Feed item component for PodTUI
|
||||||
|
* Displays a single feed/podcast in the list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Feed, FeedVisibility } from "@/types/feed";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
|
||||||
|
interface FeedItemProps {
|
||||||
|
feed: Feed;
|
||||||
|
isSelected: boolean;
|
||||||
|
showEpisodeCount?: boolean;
|
||||||
|
showLastUpdated?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedItem(props: FeedItemProps) {
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return format(date, "MMM d");
|
||||||
|
};
|
||||||
|
|
||||||
|
const episodeCount = () => props.feed.episodes.length;
|
||||||
|
const unplayedCount = () => {
|
||||||
|
// This would be calculated based on episode status
|
||||||
|
return props.feed.episodes.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityIcon = () => {
|
||||||
|
return props.feed.visibility === "public" ? "[P]" : "[*]";
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityColor = () => {
|
||||||
|
return props.feed.visibility === "public" ? theme.success : theme.warning;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinnedIndicator = () => {
|
||||||
|
return props.feed.isPinned ? "*" : " ";
|
||||||
|
};
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
if (props.compact) {
|
||||||
|
// Compact single-line view
|
||||||
|
return (
|
||||||
|
<SelectableBox
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
onMouseDown={() => {}}
|
||||||
|
>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
{props.isSelected ? ">" : " "}
|
||||||
|
</SelectableText>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
{visibilityIcon()}
|
||||||
|
</SelectableText>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
{props.feed.customName || props.feed.podcast.title}
|
||||||
|
</SelectableText>
|
||||||
|
{props.showEpisodeCount && (
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
({episodeCount()})
|
||||||
|
</SelectableText>
|
||||||
|
)}
|
||||||
|
</SelectableBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full view with details
|
||||||
|
return (
|
||||||
|
<SelectableBox
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
padding={1}
|
||||||
|
onMouseDown={() => {}}
|
||||||
|
>
|
||||||
|
{/* Title row */}
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
{props.isSelected ? ">" : " "}
|
||||||
|
</SelectableText>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
{visibilityIcon()}
|
||||||
|
</SelectableText>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
secondary
|
||||||
|
>
|
||||||
|
{pinnedIndicator()}
|
||||||
|
</SelectableText>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||||
|
</SelectableText>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={4}>
|
||||||
|
{props.showEpisodeCount && (
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
{episodeCount()} episodes ({unplayedCount()} new)
|
||||||
|
</SelectableText>
|
||||||
|
)}
|
||||||
|
{props.showLastUpdated && (
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
Updated: {formatDate(props.feed.lastUpdated)}
|
||||||
|
</SelectableText>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{props.feed.podcast.description && (
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.isSelected}
|
||||||
|
paddingLeft={4}
|
||||||
|
paddingTop={0}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
{props.feed.podcast.description.slice(0, 60)}
|
||||||
|
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
||||||
|
</SelectableText>
|
||||||
|
)}
|
||||||
|
</SelectableBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { FeedItem } from "./FeedItem";
|
|||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
interface FeedListProps {
|
interface FeedListProps {
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
@@ -21,6 +22,7 @@ interface FeedListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedList(props: FeedListProps) {
|
export function FeedList(props: FeedListProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ export function FeedList(props: FeedListProps) {
|
|||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||||
} else if (key.name === "down" || key.name === "j") {
|
} else if (key.name === "down" || key.name === "j") {
|
||||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
|
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
} else if (key.name === "return") {
|
||||||
const feed = feeds[selectedIndex()];
|
const feed = feeds[selectedIndex()];
|
||||||
if (feed && props.onOpenFeed) {
|
if (feed && props.onOpenFeed) {
|
||||||
props.onOpenFeed(feed);
|
props.onOpenFeed(feed);
|
||||||
@@ -56,6 +58,13 @@ export function FeedList(props: FeedListProps) {
|
|||||||
if (feed) {
|
if (feed) {
|
||||||
feedStore.togglePinned(feed.id);
|
feedStore.togglePinned(feed.id);
|
||||||
}
|
}
|
||||||
|
} else if (key.name === "v") {
|
||||||
|
// Toggle visibility on selected feed
|
||||||
|
const feed = feeds[selectedIndex()];
|
||||||
|
if (feed) {
|
||||||
|
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
|
||||||
|
feedStore.updateFeed(feed.id, { visibility: newVisibility });
|
||||||
|
}
|
||||||
} else if (key.name === "f") {
|
} else if (key.name === "f") {
|
||||||
// Cycle visibility filter
|
// Cycle visibility filter
|
||||||
cycleVisibilityFilter();
|
cycleVisibilityFilter();
|
||||||
@@ -136,26 +145,26 @@ export function FeedList(props: FeedListProps) {
|
|||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
{/* Header with filter controls */}
|
{/* Header with filter controls */}
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<strong>My Feeds</strong>
|
<strong>My Feeds</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">({filteredFeeds().length} feeds)</text>
|
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box border padding={0} onMouseDown={cycleVisibilityFilter}>
|
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
|
||||||
<text fg="cyan">[f] {visibilityLabel()}</text>
|
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={cycleSortField}>
|
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
|
||||||
<text fg="cyan">[s] {sortLabel()}</text>
|
<text fg={theme.primary}>[s] {sortLabel()}</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Feed list in scrollbox */}
|
{/* Feed list in scrollbox */}
|
||||||
<Show
|
<Show
|
||||||
when={filteredFeeds().length > 0}
|
when={filteredFeeds().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box border padding={2}>
|
<box border padding={2} borderColor={theme.border}>
|
||||||
<text fg="gray">
|
<text fg={theme.textMuted}>
|
||||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
No feeds found. Add podcasts from the Discover or Search tabs.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -180,9 +189,9 @@ export function FeedList(props: FeedListProps) {
|
|||||||
|
|
||||||
{/* Navigation help */}
|
{/* Navigation help */}
|
||||||
<box paddingTop={0}>
|
<box paddingTop={0}>
|
||||||
<text fg="gray">
|
<text fg={theme.textMuted}>
|
||||||
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
195
src/pages/Feed/FeedPage.tsx
Normal file
195
src/pages/Feed/FeedPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* FeedPage - Shows latest episodes across all subscribed shows
|
||||||
|
* Reverse chronological order, grouped by date
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For, Show, onMount } from "solid-js";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
import type { Feed } from "@/types/feed";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { TABS } from "@/utils/navigation";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
||||||
|
|
||||||
|
enum FeedPaneType {
|
||||||
|
FEED = 1,
|
||||||
|
}
|
||||||
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
|
const ITEMS_PER_BATCH = 50;
|
||||||
|
|
||||||
|
export function FeedPage() {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const nav = useNavigation();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
|
||||||
|
string | undefined
|
||||||
|
>();
|
||||||
|
const allEpisodes = () => feedStore.getAllEpisodesChronological();
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
const [focusedIndex, setFocusedIndex] = createSignal(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
useKeyboard(
|
||||||
|
(keyEvent: any) => {
|
||||||
|
const isDown = keybind.match("down", keyEvent);
|
||||||
|
const isUp = keybind.match("up", keyEvent);
|
||||||
|
const isCycle = keybind.match("cycle", keyEvent);
|
||||||
|
const isSelect = keybind.match("select", keyEvent);
|
||||||
|
const isInverting = keybind.isInverting(keyEvent);
|
||||||
|
|
||||||
|
if (isSelect) {
|
||||||
|
const episodes = allEpisodes();
|
||||||
|
if (episodes.length > 0 && episodes[focusedIndex()]) {
|
||||||
|
setSelectedEpisodeID(episodes[focusedIndex()].episode.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't handle pane navigation here - unified in App.tsx
|
||||||
|
if (nav.activeDepth() !== FeedPaneType.FEED) return;
|
||||||
|
|
||||||
|
const episodes = allEpisodes();
|
||||||
|
if (episodes.length === 0) return;
|
||||||
|
|
||||||
|
if (isDown && !isInverting()) {
|
||||||
|
setFocusedIndex((i) => (i + 1) % episodes.length);
|
||||||
|
} else if (isUp && isInverting()) {
|
||||||
|
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
|
||||||
|
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
|
||||||
|
setFocusedIndex((i) => (i + 1) % episodes.length);
|
||||||
|
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
|
||||||
|
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ release: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return format(date, "MMM d, yyyy");
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupEpisodesByDate = () => {
|
||||||
|
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {};
|
||||||
|
|
||||||
|
for (const item of allEpisodes()) {
|
||||||
|
const dateKey = formatDate(new Date(item.episode.pubDate));
|
||||||
|
if (!groups[dateKey]) {
|
||||||
|
groups[dateKey] = [];
|
||||||
|
}
|
||||||
|
groups[dateKey].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => {
|
||||||
|
// Convert date strings back to Date objects for proper chronological sorting
|
||||||
|
const dateA = new Date(a);
|
||||||
|
const dateB = new Date(b);
|
||||||
|
// Sort in descending order (newest first)
|
||||||
|
return dateB.getTime() - dateA.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`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={
|
||||||
|
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent
|
||||||
|
}
|
||||||
|
backgroundColor={theme.background}
|
||||||
|
flexDirection="column"
|
||||||
|
height="100%"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={allEpisodes().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={2}>
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
No episodes yet. Subscribe to shows from Discover or Search.
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox
|
||||||
|
height="100%"
|
||||||
|
focused={nav.activeDepth() == FeedPaneType.FEED}
|
||||||
|
>
|
||||||
|
<For each={groupEpisodesByDate()}>
|
||||||
|
{([date, items]) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<SelectableText selected={() => false} primary>
|
||||||
|
{date}
|
||||||
|
</SelectableText>
|
||||||
|
<For each={items}>
|
||||||
|
{(item) => {
|
||||||
|
const isSelected = () => {
|
||||||
|
if (
|
||||||
|
nav.activeTab() == TABS.FEED &&
|
||||||
|
nav.activeDepth() == FeedPaneType.FEED &&
|
||||||
|
selectedEpisodeID() &&
|
||||||
|
selectedEpisodeID() === item.episode.id
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const isFocused = () => {
|
||||||
|
const episodes = allEpisodes();
|
||||||
|
const currentIndex = episodes.findIndex(
|
||||||
|
(e: any) => e.episode.id === item.episode.id,
|
||||||
|
);
|
||||||
|
return currentIndex === focusedIndex();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<SelectableBox
|
||||||
|
selected={isSelected}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
paddingTop={0}
|
||||||
|
paddingBottom={0}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSelectedEpisodeID(item.episode.id);
|
||||||
|
const episodes = allEpisodes();
|
||||||
|
setFocusedIndex(
|
||||||
|
episodes.findIndex((e: any) => e.episode.id === item.episode.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectableText selected={isSelected} primary>
|
||||||
|
{item.episode.title}
|
||||||
|
</SelectableText>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<SelectableText selected={isSelected} primary>
|
||||||
|
{item.feed.podcast.title}
|
||||||
|
</SelectableText>
|
||||||
|
<SelectableText selected={isSelected} tertiary>
|
||||||
|
{formatDuration(item.episode.duration)}
|
||||||
|
</SelectableText>
|
||||||
|
</box>
|
||||||
|
</SelectableBox>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,30 +4,77 @@
|
|||||||
* Right panel: episodes for the selected show
|
* Right panel: episodes for the selected show
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show, createMemo, createEffect } from "solid-js";
|
import { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { useKeyboard } from "@opentui/solid";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import type { Episode } from "@/types/episode";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import type { Feed } from "@/types/feed";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
||||||
|
|
||||||
type MyShowsPageProps = {
|
enum MyShowsPaneType {
|
||||||
focused: boolean;
|
SHOWS = 1,
|
||||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
|
EPISODES = 2,
|
||||||
onExit?: () => void;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
type FocusPane = "shows" | "episodes";
|
export const MyShowsPaneCount = 2;
|
||||||
|
|
||||||
export function MyShowsPage(props: MyShowsPageProps) {
|
export function MyShowsPage() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows");
|
const audioNav = useAudioNavStore();
|
||||||
|
const [isRefreshing, setIsRefreshing] = createSignal(false);
|
||||||
const [showIndex, setShowIndex] = createSignal(0);
|
const [showIndex, setShowIndex] = createSignal(0);
|
||||||
const [episodeIndex, setEpisodeIndex] = createSignal(0);
|
const [episodeIndex, setEpisodeIndex] = createSignal(0);
|
||||||
const [isRefreshing, setIsRefreshing] = createSignal(false);
|
const { theme } = useTheme();
|
||||||
|
const mutedColor = () => theme.muted || theme.text;
|
||||||
|
const nav = useNavigation();
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
useKeyboard(
|
||||||
|
(keyEvent: any) => {
|
||||||
|
const isDown = keybind.match("down", keyEvent);
|
||||||
|
const isUp = keybind.match("up", keyEvent);
|
||||||
|
const isCycle = keybind.match("cycle", keyEvent);
|
||||||
|
const isSelect = keybind.match("select", keyEvent);
|
||||||
|
const isInverting = keybind.isInverting(keyEvent);
|
||||||
|
|
||||||
|
const shows = feedStore.getFilteredFeeds();
|
||||||
|
const episodesList = episodes();
|
||||||
|
|
||||||
|
if (isSelect) {
|
||||||
|
if (shows.length > 0 && showIndex() < shows.length) {
|
||||||
|
setShowIndex(showIndex() + 1);
|
||||||
|
}
|
||||||
|
if (episodesList.length > 0 && episodeIndex() < episodesList.length) {
|
||||||
|
setEpisodeIndex(episodeIndex() + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't handle pane navigation here - unified in App.tsx
|
||||||
|
if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return;
|
||||||
|
|
||||||
|
if (episodesList.length > 0) {
|
||||||
|
if (isDown && !isInverting()) {
|
||||||
|
setEpisodeIndex((i) => (i + 1) % episodesList.length);
|
||||||
|
} else if (isUp && isInverting()) {
|
||||||
|
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
|
||||||
|
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
|
||||||
|
setEpisodeIndex((i) => (i + 1) % episodesList.length);
|
||||||
|
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
|
||||||
|
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ release: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/** Threshold: load more when within this many items of the end */
|
/** Threshold: load more when within this many items of the end */
|
||||||
const LOAD_MORE_THRESHOLD = 5;
|
const LOAD_MORE_THRESHOLD = 5;
|
||||||
@@ -35,9 +82,7 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
const shows = () => feedStore.getFilteredFeeds();
|
const shows = () => feedStore.getFilteredFeeds();
|
||||||
|
|
||||||
const selectedShow = createMemo(() => {
|
const selectedShow = createMemo(() => {
|
||||||
const s = shows();
|
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation
|
||||||
const idx = showIndex();
|
|
||||||
return idx < s.length ? s[idx] : undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const episodes = createMemo(() => {
|
const episodes = createMemo(() => {
|
||||||
@@ -48,23 +93,6 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect when user navigates near the bottom and load more episodes
|
|
||||||
createEffect(() => {
|
|
||||||
const idx = episodeIndex();
|
|
||||||
const eps = episodes();
|
|
||||||
const show = selectedShow();
|
|
||||||
if (!show || eps.length === 0) return;
|
|
||||||
|
|
||||||
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD;
|
|
||||||
if (
|
|
||||||
nearBottom &&
|
|
||||||
feedStore.hasMoreEpisodes(show.id) &&
|
|
||||||
!feedStore.isLoadingMore()
|
|
||||||
) {
|
|
||||||
feedStore.loadMoreEpisodes(show.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
const formatDate = (date: Date): string => {
|
||||||
return format(date, "MMM d, yyyy");
|
return format(date, "MMM d, yyyy");
|
||||||
};
|
};
|
||||||
@@ -95,23 +123,6 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get download status color */
|
|
||||||
const downloadColor = (episodeId: string): string => {
|
|
||||||
const status = downloadStore.getDownloadStatus(episodeId);
|
|
||||||
switch (status) {
|
|
||||||
case DownloadStatus.QUEUED:
|
|
||||||
return "yellow";
|
|
||||||
case DownloadStatus.DOWNLOADING:
|
|
||||||
return "cyan";
|
|
||||||
case DownloadStatus.COMPLETED:
|
|
||||||
return "green";
|
|
||||||
case DownloadStatus.FAILED:
|
|
||||||
return "red";
|
|
||||||
default:
|
|
||||||
return "gray";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
@@ -128,114 +139,48 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
setEpisodeIndex(0);
|
setEpisodeIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboard((key) => {
|
/** Get download status color */
|
||||||
if (!props.focused) return;
|
const downloadColor = (episodeId: string): string => {
|
||||||
|
const status = downloadStore.getDownloadStatus(episodeId);
|
||||||
const pane = focusPane();
|
switch (status) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
// Navigate between panes
|
return theme.warning.toString();
|
||||||
if (key.name === "right" || key.name === "l") {
|
case DownloadStatus.DOWNLOADING:
|
||||||
if (pane === "shows" && selectedShow()) {
|
return theme.primary.toString();
|
||||||
setFocusPane("episodes");
|
case DownloadStatus.COMPLETED:
|
||||||
setEpisodeIndex(0);
|
return theme.success.toString();
|
||||||
}
|
case DownloadStatus.FAILED:
|
||||||
return;
|
return theme.error.toString();
|
||||||
}
|
default:
|
||||||
if (key.name === "left" || key.name === "h") {
|
return mutedColor().toString();
|
||||||
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") {
|
return (
|
||||||
const s = shows();
|
<box flexDirection="row" flexGrow={1} width="100%">
|
||||||
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 === "d") {
|
|
||||||
const ep = eps[episodeIndex()];
|
|
||||||
const show = selectedShow();
|
|
||||||
if (ep && show) {
|
|
||||||
const status = downloadStore.getDownloadStatus(ep.id);
|
|
||||||
if (
|
|
||||||
status === DownloadStatus.NONE ||
|
|
||||||
status === DownloadStatus.FAILED
|
|
||||||
) {
|
|
||||||
downloadStore.startDownload(ep, show.id);
|
|
||||||
} else if (
|
|
||||||
status === DownloadStatus.DOWNLOADING ||
|
|
||||||
status === DownloadStatus.QUEUED
|
|
||||||
) {
|
|
||||||
downloadStore.cancelDownload(ep.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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");
|
|
||||||
key.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
showsPanel: () => (
|
|
||||||
<box flexDirection="column" height="100%">
|
<box flexDirection="column" height="100%">
|
||||||
<Show when={isRefreshing()}>
|
<Show when={isRefreshing()}>
|
||||||
<text fg="yellow">Refreshing...</text>
|
<text fg={theme.warning}>Refreshing...</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={shows().length > 0}
|
when={shows().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg="gray">
|
<text fg={theme.muted}>
|
||||||
No shows yet. Subscribe from Discover or Search.
|
No shows yet. Subscribe from Discover or Search.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
|
border
|
||||||
height="100%"
|
height="100%"
|
||||||
focused={props.focused && focusPane() === "shows"}
|
borderColor={
|
||||||
|
nav.activeDepth() == MyShowsPaneType.SHOWS
|
||||||
|
? theme.accent
|
||||||
|
: theme.border
|
||||||
|
}
|
||||||
|
focused={nav.activeDepth() == MyShowsPaneType.SHOWS}
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => (
|
{(feed, index) => (
|
||||||
@@ -244,49 +189,64 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={index() === showIndex() ? "#333" : undefined}
|
backgroundColor={
|
||||||
|
index() === showIndex() ? theme.primary : undefined
|
||||||
|
}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
setShowIndex(index());
|
setShowIndex(index());
|
||||||
setEpisodeIndex(0);
|
setEpisodeIndex(0);
|
||||||
|
audioNav.setSource(
|
||||||
|
AudioSource.MY_SHOWS,
|
||||||
|
selectedShow()?.podcast.id,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={index() === showIndex() ? "cyan" : "gray"}>
|
<text
|
||||||
|
fg={index() === showIndex() ? theme.surface : theme.text}
|
||||||
|
>
|
||||||
{index() === showIndex() ? ">" : " "}
|
{index() === showIndex() ? ">" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={index() === showIndex() ? "white" : undefined}>
|
<text
|
||||||
|
fg={index() === showIndex() ? theme.surface : theme.text}
|
||||||
|
>
|
||||||
{feed.customName || feed.podcast.title}
|
{feed.customName || feed.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">({feed.episodes.length})</text>
|
<text fg={index() === showIndex() ? undefined : theme.text}>
|
||||||
|
({feed.episodes.length})
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
),
|
|
||||||
|
|
||||||
episodesPanel: () => (
|
|
||||||
<box flexDirection="column" height="100%">
|
<box flexDirection="column" height="100%">
|
||||||
<Show
|
|
||||||
when={selectedShow()}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg="gray">Select a show</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
<Show
|
||||||
when={episodes().length > 0}
|
when={selectedShow()}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg="gray">No episodes. Press [r] to refresh.</text>
|
<text fg={theme.muted}>Select a show</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<scrollbox
|
<Show
|
||||||
height="100%"
|
when={episodes().length > 0}
|
||||||
focused={props.focused && focusPane() === "episodes"}
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.muted}>No episodes. Press [r] to refresh.</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
<scrollbox
|
||||||
|
border
|
||||||
|
height="100%"
|
||||||
|
borderColor={
|
||||||
|
nav.activeDepth() == MyShowsPaneType.EPISODES
|
||||||
|
? theme.accent
|
||||||
|
: theme.border
|
||||||
|
}
|
||||||
|
focused={nav.activeDepth() == MyShowsPaneType.EPISODES}
|
||||||
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(episode, index) => (
|
{(episode, index) => (
|
||||||
<box
|
<box
|
||||||
@@ -295,16 +255,26 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
index() === episodeIndex() ? "#333" : undefined
|
index() === episodeIndex() ? theme.primary : undefined
|
||||||
}
|
}
|
||||||
onMouseDown={() => setEpisodeIndex(index())}
|
onMouseDown={() => setEpisodeIndex(index())}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={index() === episodeIndex() ? "cyan" : "gray"}>
|
<text
|
||||||
|
fg={
|
||||||
|
index() === episodeIndex()
|
||||||
|
? theme.surface
|
||||||
|
: theme.text
|
||||||
|
}
|
||||||
|
>
|
||||||
{index() === episodeIndex() ? ">" : " "}
|
{index() === episodeIndex() ? ">" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
fg={index() === episodeIndex() ? "white" : undefined}
|
fg={
|
||||||
|
index() === episodeIndex()
|
||||||
|
? theme.surface
|
||||||
|
: theme.text
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{episode.episodeNumber
|
{episode.episodeNumber
|
||||||
? `#${episode.episodeNumber} `
|
? `#${episode.episodeNumber} `
|
||||||
@@ -313,8 +283,14 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
<text
|
||||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
fg={index() === episodeIndex() ? undefined : theme.info}
|
||||||
|
>
|
||||||
|
{formatDate(episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.muted}>
|
||||||
|
{formatDuration(episode.duration)}
|
||||||
|
</text>
|
||||||
<Show when={downloadLabel(episode.id)}>
|
<Show when={downloadLabel(episode.id)}>
|
||||||
<text fg={downloadColor(episode.id)}>
|
<text fg={downloadColor(episode.id)}>
|
||||||
{downloadLabel(episode.id)}
|
{downloadLabel(episode.id)}
|
||||||
@@ -326,7 +302,7 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
</For>
|
</For>
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
<Show when={feedStore.isLoadingMore()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<text fg="yellow">Loading more episodes...</text>
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
@@ -337,16 +313,13 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<text fg="gray">Scroll down for more episodes</text>
|
<text fg={theme.muted}>Scroll down for more episodes</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
),
|
</box>
|
||||||
|
);
|
||||||
focusPane,
|
|
||||||
selectedShow,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BackendName } from "../utils/audio-player"
|
import type { BackendName } from "../utils/audio-player"
|
||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
type PlaybackControlsProps = {
|
type PlaybackControlsProps = {
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
@@ -22,39 +23,40 @@ const BACKEND_LABELS: Record<BackendName, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={1} alignItems="center" border padding={1}>
|
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}>
|
||||||
<box border padding={0} onMouseDown={props.onPrev}>
|
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}>
|
||||||
<text fg="cyan">[Prev]</text>
|
<text fg={theme.primary}>[Prev]</text>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={props.onToggle}>
|
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}>
|
||||||
<text fg="cyan">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={props.onNext}>
|
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}>
|
||||||
<text fg="cyan">[Next]</text>
|
<text fg={theme.primary}>[Next]</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg="gray">Vol</text>
|
<text fg={theme.textMuted}>Vol</text>
|
||||||
<text fg="white">{Math.round(props.volume * 100)}%</text>
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg="gray">Speed</text>
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
<text fg="white">{props.speed}x</text>
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
</box>
|
</box>
|
||||||
{props.backendName && props.backendName !== "none" && (
|
{props.backendName && props.backendName !== "none" && (
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg="gray">via</text>
|
<text fg={theme.textMuted}>via</text>
|
||||||
<text fg="cyan">{BACKEND_LABELS[props.backendName]}</text>
|
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
{props.backendName === "none" && (
|
{props.backendName === "none" && (
|
||||||
<box marginLeft={2}>
|
<box marginLeft={2}>
|
||||||
<text fg="yellow">No audio player found</text>
|
<text fg={theme.warning}>No audio player found</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
{props.hasAudioUrl === false && (
|
{props.hasAudioUrl === false && (
|
||||||
<box marginLeft={2}>
|
<box marginLeft={2}>
|
||||||
<text fg="yellow">No audio URL</text>
|
<text fg={theme.warning}>No audio URL</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</box>
|
</box>
|
||||||
112
src/pages/Player/PlayerPage.tsx
Normal file
112
src/pages/Player/PlayerPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
import { useKeybinds } from "@/context/KeybindContext";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { onMount } from "solid-js";
|
||||||
|
|
||||||
|
enum PlayerPaneType {
|
||||||
|
PLAYER = 1,
|
||||||
|
}
|
||||||
|
export const PlayerPaneCount = 1;
|
||||||
|
|
||||||
|
export function PlayerPage() {
|
||||||
|
const audio = useAudio();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
useKeyboard(
|
||||||
|
(keyEvent: any) => {
|
||||||
|
const isInverting = keybind.isInverting(keyEvent);
|
||||||
|
|
||||||
|
if (keybind.match("audio-toggle", keyEvent)) {
|
||||||
|
audio.togglePlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keybind.match("audio-seek-forward", keyEvent)) {
|
||||||
|
audio.seek(audio.currentEpisode()?.duration ?? 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keybind.match("audio-seek-backward", keyEvent)) {
|
||||||
|
audio.seek(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ release: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressPercent = () => {
|
||||||
|
const d = audio.duration();
|
||||||
|
if (d <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((audio.position() / d) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} width="100%">
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<strong>Now Playing</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.muted}>
|
||||||
|
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
||||||
|
{progressPercent()}%)
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{audio.error() && <text fg={theme.error}>{audio.error()}</text>}
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border}
|
||||||
|
padding={1}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<strong>{audio.currentEpisode()?.title}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.muted}>{audio.currentEpisode()?.description}</text>
|
||||||
|
|
||||||
|
<RealtimeWaveform
|
||||||
|
visualizerConfig={(() => {
|
||||||
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
|
return {
|
||||||
|
bars: viz.bars,
|
||||||
|
noiseReduction: viz.noiseReduction,
|
||||||
|
lowCutOff: viz.lowCutOff,
|
||||||
|
highCutOff: viz.highCutOff,
|
||||||
|
};
|
||||||
|
})()}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<PlaybackControls
|
||||||
|
isPlaying={audio.isPlaying()}
|
||||||
|
volume={audio.volume()}
|
||||||
|
speed={audio.speed()}
|
||||||
|
backendName={audio.backendName()}
|
||||||
|
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
||||||
|
onToggle={audio.togglePlayback}
|
||||||
|
onPrev={() => audio.seek(0)}
|
||||||
|
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows)
|
||||||
|
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||||
|
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,25 +14,12 @@ import {
|
|||||||
type CavaCoreConfig,
|
type CavaCoreConfig,
|
||||||
} from "@/utils/cavacore";
|
} from "@/utils/cavacore";
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type RealtimeWaveformProps = {
|
export type RealtimeWaveformProps = {
|
||||||
/** Audio URL — used to start the ffmpeg decode stream */
|
|
||||||
audioUrl: string;
|
|
||||||
/** Current playback position in seconds */
|
|
||||||
position: number;
|
|
||||||
/** Total duration in seconds */
|
|
||||||
duration: number;
|
|
||||||
/** Whether audio is currently playing */
|
|
||||||
isPlaying: boolean;
|
|
||||||
/** Playback speed multiplier (default: 1) */
|
|
||||||
speed?: number;
|
|
||||||
/** Number of frequency bars / columns */
|
|
||||||
resolution?: number;
|
|
||||||
/** Callback when user clicks to seek */
|
|
||||||
onSeek?: (seconds: number) => void;
|
|
||||||
/** Visualizer configuration overrides */
|
|
||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
visualizerConfig?: Partial<CavaCoreConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +45,8 @@ const SAMPLES_PER_FRAME = 512;
|
|||||||
// ── Component ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||||
const resolution = () => props.resolution ?? 32;
|
const { theme } = useTheme();
|
||||||
|
const audio = useAudio();
|
||||||
|
|
||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
@@ -95,7 +83,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides
|
// Initialize cavacore with current resolution + any overrides
|
||||||
const config: CavaCoreConfig = {
|
const config: CavaCoreConfig = {
|
||||||
bars: resolution(),
|
bars: 32,
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
...props.visualizerConfig,
|
...props.visualizerConfig,
|
||||||
@@ -151,27 +139,17 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
setBarData(Array.from(output));
|
setBarData(Array.from(output));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Single unified effect: respond to all prop changes ─────────────
|
|
||||||
//
|
|
||||||
// Instead of three competing effects that each independently call
|
|
||||||
// startVisualization() and race against each other, we use ONE effect
|
|
||||||
// that tracks all relevant inputs. Position is read with untrack()
|
|
||||||
// so normal playback drift doesn't trigger restarts.
|
|
||||||
//
|
|
||||||
// SolidJS on() with an array of accessors compares each element
|
|
||||||
// individually, so the effect only fires when a value actually changes.
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
[
|
[
|
||||||
() => props.isPlaying,
|
audio.isPlaying,
|
||||||
() => props.audioUrl,
|
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here
|
||||||
() => props.speed ?? 1,
|
audio.speed,
|
||||||
resolution,
|
() => 32,
|
||||||
],
|
],
|
||||||
([playing, url, speed]) => {
|
([playing, url, speed]) => {
|
||||||
if (playing && url) {
|
if (playing && url) {
|
||||||
const pos = untrack(() => props.position);
|
const pos = untrack(audio.position);
|
||||||
startVisualization(url, pos, speed);
|
startVisualization(url, pos, speed);
|
||||||
} else {
|
} else {
|
||||||
stopVisualization();
|
stopVisualization();
|
||||||
@@ -189,23 +167,19 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
let lastSyncPosition = 0;
|
let lastSyncPosition = 0;
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(audio.position, (pos) => {
|
||||||
() => props.position,
|
if (!audio.isPlaying || !reader?.running) {
|
||||||
(pos) => {
|
|
||||||
if (!props.isPlaying || !reader?.running) {
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Math.abs(pos - lastSyncPosition);
|
|
||||||
lastSyncPosition = pos;
|
lastSyncPosition = pos;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (delta > 2) {
|
const delta = Math.abs(pos - lastSyncPosition);
|
||||||
const speed = props.speed ?? 1;
|
lastSyncPosition = pos;
|
||||||
reader.restart(pos, speed);
|
|
||||||
}
|
if (delta > 2) {
|
||||||
},
|
reader.restart(pos, audio.speed() ?? 1);
|
||||||
),
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
@@ -224,11 +198,13 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const playedRatio = () =>
|
const playedRatio = () =>
|
||||||
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
|
audio.duration() <= 0
|
||||||
|
? 0
|
||||||
|
: Math.min(1, audio.position() / audio.duration());
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = barData();
|
||||||
const numBars = resolution();
|
const numBars = 32;
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
// If no data yet, show empty placeholder
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
@@ -241,7 +217,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const played = Math.floor(numBars * playedRatio());
|
const played = Math.floor(numBars * playedRatio());
|
||||||
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
|
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
||||||
const futureColor = "#3b4252";
|
const futureColor = "#3b4252";
|
||||||
|
|
||||||
const playedChars = bars
|
const playedChars = bars
|
||||||
@@ -263,17 +239,17 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (event: { x: number }) => {
|
const handleClick = (event: { x: number }) => {
|
||||||
const numBars = resolution();
|
const numBars = 32;
|
||||||
const ratio = numBars === 0 ? 0 : event.x / numBars;
|
const ratio = event.x / numBars;
|
||||||
const next = Math.max(
|
const next = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(props.duration, Math.round(props.duration * ratio)),
|
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
||||||
);
|
);
|
||||||
props.onSeek?.(next);
|
audio.seek(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border padding={1} onMouseDown={handleClick}>
|
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
|
||||||
{renderLine()}
|
{renderLine()}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
95
src/pages/Search/ResultCard.tsx
Normal file
95
src/pages/Search/ResultCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import type { SearchResult } from "@/types/source";
|
||||||
|
import { SourceBadge } from "./SourceBadge";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
|
||||||
|
type ResultCardProps = {
|
||||||
|
result: SearchResult;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onSubscribe?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResultCard(props: ResultCardProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const podcast = () => props.result.podcast;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectableBox
|
||||||
|
selected={() => props.selected}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
onMouseDown={props.onSelect}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={2} alignItems="center">
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.selected}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
<strong>{podcast().title}</strong>
|
||||||
|
</SelectableText>
|
||||||
|
<SourceBadge
|
||||||
|
sourceId={props.result.sourceId}
|
||||||
|
sourceName={props.result.sourceName}
|
||||||
|
sourceType={props.result.sourceType}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<Show when={podcast().isSubscribed}>
|
||||||
|
<text fg={theme.success}>[Subscribed]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Show when={podcast().author}>
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.selected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
by {podcast().author}
|
||||||
|
</SelectableText>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={podcast().description}>
|
||||||
|
{(description) => (
|
||||||
|
<SelectableText
|
||||||
|
selected={() => props.selected}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
{description().length > 120
|
||||||
|
? description().slice(0, 120) + "..."
|
||||||
|
: description()}
|
||||||
|
</SelectableText>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={(podcast().categories ?? []).length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
{(podcast().categories ?? []).slice(0, 3).map((category) => (
|
||||||
|
<text fg={theme.warning}>[{category}]</text>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!podcast().isSubscribed}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
width={18}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.stopPropagation?.();
|
||||||
|
props.onSubscribe?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={theme.primary}>[+] Add to Feeds</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</SelectableBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Show } from "solid-js";
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import type { SearchResult } from "@/types/source";
|
import type { SearchResult } from "@/types/source";
|
||||||
import { SourceBadge } from "./SourceBadge";
|
import { SourceBadge } from "./SourceBadge";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
type ResultDetailProps = {
|
type ResultDetailProps = {
|
||||||
result?: SearchResult;
|
result?: SearchResult;
|
||||||
@@ -9,15 +10,16 @@ type ResultDetailProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ResultDetail(props: ResultDetailProps) {
|
export function ResultDetail(props: ResultDetailProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border padding={1} gap={1} height="100%">
|
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
|
||||||
<Show
|
<Show
|
||||||
when={props.result}
|
when={props.result}
|
||||||
fallback={<text fg="gray">Select a result to see details.</text>}
|
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
|
||||||
>
|
>
|
||||||
{(result) => (
|
{(result) => (
|
||||||
<>
|
<>
|
||||||
<text fg="white">
|
<text fg={theme.text}>
|
||||||
<strong>{result().podcast.title}</strong>
|
<strong>{result().podcast.title}</strong>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
@@ -28,24 +30,24 @@ export function ResultDetail(props: ResultDetailProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Show when={result().podcast.author}>
|
<Show when={result().podcast.author}>
|
||||||
<text fg="gray">by {result().podcast.author}</text>
|
<text fg={theme.textMuted}>by {result().podcast.author}</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={result().podcast.description}>
|
<Show when={result().podcast.description}>
|
||||||
<text fg="gray">{result().podcast.description}</text>
|
<text fg={theme.textMuted}>{result().podcast.description}</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
{(result().podcast.categories ?? []).map((category) => (
|
{(result().podcast.categories ?? []).map((category) => (
|
||||||
<text fg="yellow">[{category}]</text>
|
<text fg={theme.warning}>[{category}]</text>
|
||||||
))}
|
))}
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
|
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
|
||||||
|
|
||||||
<text fg="gray">
|
<text fg={theme.textMuted}>
|
||||||
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
@@ -58,12 +60,12 @@ export function ResultDetail(props: ResultDetailProps) {
|
|||||||
width={18}
|
width={18}
|
||||||
onMouseDown={() => props.onSubscribe?.(result())}
|
onMouseDown={() => props.onSubscribe?.(result())}
|
||||||
>
|
>
|
||||||
<text fg="cyan">[+] Add to Feeds</text>
|
<text fg={theme.primary}>[+] Add to Feeds</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={result().podcast.isSubscribed}>
|
<Show when={result().podcast.isSubscribed}>
|
||||||
<text fg="green">Already subscribed</text>
|
<text fg={theme.success}>Already subscribed</text>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable"
|
||||||
|
|
||||||
type SearchHistoryProps = {
|
type SearchHistoryProps = {
|
||||||
history: string[]
|
history: string[]
|
||||||
@@ -15,6 +17,7 @@ type SearchHistoryProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SearchHistory(props: SearchHistoryProps) {
|
export function SearchHistory(props: SearchHistoryProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const handleSearchClick = (index: number, query: string) => {
|
const handleSearchClick = (index: number, query: string) => {
|
||||||
props.onChange?.(index)
|
props.onChange?.(index)
|
||||||
props.onSelect?.(query)
|
props.onSelect?.(query)
|
||||||
@@ -27,19 +30,19 @@ export function SearchHistory(props: SearchHistoryProps) {
|
|||||||
return (
|
return (
|
||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg="gray">Recent Searches</text>
|
<text fg={theme.textMuted}>Recent Searches</text>
|
||||||
<Show when={props.history.length > 0}>
|
<Show when={props.history.length > 0}>
|
||||||
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
||||||
<text fg="red">[Clear All]</text>
|
<text fg={theme.error}>[Clear All]</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={props.history.length > 0}
|
when={props.history.length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg="gray">No recent searches</text>
|
<text fg={theme.textMuted}>No recent searches</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -50,23 +53,31 @@ export function SearchHistory(props: SearchHistoryProps) {
|
|||||||
const isSelected = () => index() === props.selectedIndex && props.focused
|
const isSelected = () => index() === props.selectedIndex && props.focused
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<SelectableBox
|
||||||
|
selected={isSelected}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
padding={0}
|
padding={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={isSelected() ? "#333" : undefined}
|
|
||||||
onMouseDown={() => handleSearchClick(index(), query)}
|
onMouseDown={() => handleSearchClick(index(), query)}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<SelectableText
|
||||||
<text fg="gray">{">"}</text>
|
selected={isSelected}
|
||||||
<text fg={isSelected() ? "cyan" : "white"}>{query}</text>
|
tertiary
|
||||||
</box>
|
>
|
||||||
|
{">"}
|
||||||
|
</SelectableText>
|
||||||
|
<SelectableText
|
||||||
|
selected={isSelected}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
{query}
|
||||||
|
</SelectableText>
|
||||||
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
||||||
<text fg="red">[x]</text>
|
<text fg={theme.error}>[x]</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</SelectableBox>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
210
src/pages/Search/SearchPage.tsx
Normal file
210
src/pages/Search/SearchPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* SearchPage component - Main search interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, createEffect, Show, onMount } 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";
|
||||||
|
import { MyShowsPage } from "../MyShows/MyShowsPage";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
||||||
|
|
||||||
|
enum SearchPaneType {
|
||||||
|
INPUT = 1,
|
||||||
|
RESULTS = 2,
|
||||||
|
HISTORY = 3,
|
||||||
|
}
|
||||||
|
export const SearchPaneCount = 3;
|
||||||
|
|
||||||
|
export function SearchPage() {
|
||||||
|
const searchStore = useSearchStore();
|
||||||
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
|
const [resultIndex, setResultIndex] = createSignal(0);
|
||||||
|
const [historyIndex, setHistoryIndex] = createSignal(0);
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
useKeyboard(
|
||||||
|
(keyEvent: any) => {
|
||||||
|
const isDown = keybind.match("down", keyEvent);
|
||||||
|
const isUp = keybind.match("up", keyEvent);
|
||||||
|
const isCycle = keybind.match("cycle", keyEvent);
|
||||||
|
const isSelect = keybind.match("select", keyEvent);
|
||||||
|
const isInverting = keybind.isInverting(keyEvent);
|
||||||
|
|
||||||
|
if (isSelect) {
|
||||||
|
const results = searchStore.results();
|
||||||
|
if (results.length > 0 && resultIndex() < results.length) {
|
||||||
|
setResultIndex(resultIndex() + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't handle pane navigation here - unified in App.tsx
|
||||||
|
if (nav.activeDepth() !== SearchPaneType.RESULTS) return;
|
||||||
|
|
||||||
|
const results = searchStore.results();
|
||||||
|
if (results.length === 0) return;
|
||||||
|
|
||||||
|
if (isDown && !isInverting()) {
|
||||||
|
setResultIndex((i) => (i + 1) % results.length);
|
||||||
|
} else if (isUp && isInverting()) {
|
||||||
|
setResultIndex((i) => (i - 1 + results.length) % results.length);
|
||||||
|
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
|
||||||
|
setResultIndex((i) => (i + 1) % results.length);
|
||||||
|
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
|
||||||
|
setResultIndex((i) => (i - 1 + results.length) % results.length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ release: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const query = inputValue().trim();
|
||||||
|
if (query) {
|
||||||
|
await searchStore.search(query);
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
//setFocusArea("results"); //TODO: move level
|
||||||
|
setResultIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHistorySelect = async (query: string) => {
|
||||||
|
setInputValue(query);
|
||||||
|
await searchStore.search(query);
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
//setFocusArea("results"); //TODO: move level
|
||||||
|
setResultIndex(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultSelect = (result: SearchResult) => {
|
||||||
|
//props.onSubscribe?.(result);
|
||||||
|
searchStore.markSubscribed(result.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%" gap={1} width="100%">
|
||||||
|
{/* Search Header */}
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<strong>Search Podcasts</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg="gray">Search:</text>
|
||||||
|
<input
|
||||||
|
value={inputValue()}
|
||||||
|
onInput={(value) => {
|
||||||
|
setInputValue(value);
|
||||||
|
}}
|
||||||
|
placeholder="Enter podcast name, topic, or author..."
|
||||||
|
focused={nav.activeDepth() === SearchPaneType.INPUT}
|
||||||
|
width={50}
|
||||||
|
/>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
onMouseDown={handleSearch}
|
||||||
|
>
|
||||||
|
<text fg={theme.primary}>[Enter] Search</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Show when={searchStore.isSearching()}>
|
||||||
|
<text fg={theme.warning}>Searching...</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={searchStore.error()}>
|
||||||
|
<text fg={theme.error}>{searchStore.error()}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Main Content - Results or History */}
|
||||||
|
<box flexDirection="row" height="100%" gap={2}>
|
||||||
|
{/* Results Panel */}
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={1}
|
||||||
|
border
|
||||||
|
borderColor={
|
||||||
|
nav.activeDepth() === SearchPaneType.RESULTS
|
||||||
|
? theme.accent
|
||||||
|
: theme.border
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
nav.activeDepth() === SearchPaneType.RESULTS
|
||||||
|
? theme.primary
|
||||||
|
: theme.muted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Results ({searchStore.results().length})
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<Show
|
||||||
|
when={searchStore.results().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={2}>
|
||||||
|
<text fg={theme.muted}>
|
||||||
|
{searchStore.query()
|
||||||
|
? "No results found"
|
||||||
|
: "Enter a search term to find podcasts"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchResults
|
||||||
|
results={searchStore.results()}
|
||||||
|
selectedIndex={resultIndex()}
|
||||||
|
focused={nav.activeDepth() === SearchPaneType.RESULTS}
|
||||||
|
onSelect={handleResultSelect}
|
||||||
|
onChange={setResultIndex}
|
||||||
|
isSearching={searchStore.isSearching()}
|
||||||
|
error={searchStore.error()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* History Sidebar */}
|
||||||
|
<box width={30} border borderColor={theme.border}>
|
||||||
|
<box padding={1} flexDirection="column">
|
||||||
|
<box paddingBottom={1}>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
nav.activeDepth() === SearchPaneType.HISTORY
|
||||||
|
? theme.primary
|
||||||
|
: theme.muted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<SearchHistory
|
||||||
|
history={searchStore.history()}
|
||||||
|
selectedIndex={historyIndex()}
|
||||||
|
focused={nav.activeDepth() === SearchPaneType.HISTORY}
|
||||||
|
onSelect={handleHistorySelect}
|
||||||
|
onRemove={searchStore.removeFromHistory}
|
||||||
|
onClear={searchStore.clearHistory}
|
||||||
|
onChange={setHistoryIndex}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SourceType } from "@/types/source";
|
import { SourceType } from "@/types/source";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
type SourceBadgeProps = {
|
type SourceBadgeProps = {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
@@ -14,21 +15,29 @@ const typeLabel = (sourceType?: SourceType) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const typeColor = (sourceType?: SourceType) => {
|
const typeColor = (sourceType?: SourceType) => {
|
||||||
if (sourceType === SourceType.API) return "cyan";
|
if (sourceType === SourceType.API) return theme.primary;
|
||||||
if (sourceType === SourceType.RSS) return "green";
|
if (sourceType === SourceType.RSS) return theme.success;
|
||||||
if (sourceType === SourceType.CUSTOM) return "yellow";
|
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
||||||
return "gray";
|
return theme.textMuted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SourceBadge(props: SourceBadgeProps) {
|
export function SourceBadge(props: SourceBadgeProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const label = () => props.sourceName || props.sourceId;
|
const label = () => props.sourceName || props.sourceId;
|
||||||
|
|
||||||
|
const typeColor = (sourceType?: SourceType) => {
|
||||||
|
if (sourceType === SourceType.API) return theme.primary;
|
||||||
|
if (sourceType === SourceType.RSS) return theme.success;
|
||||||
|
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
||||||
|
return theme.textMuted;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={1} padding={0}>
|
<box flexDirection="row" gap={1} padding={0}>
|
||||||
<text fg={typeColor(props.sourceType)}>
|
<text fg={typeColor(props.sourceType)}>
|
||||||
[{typeLabel(props.sourceType)}]
|
[{typeLabel(props.sourceType)}]
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">{label()}</text>
|
<text fg={theme.textMuted}>{label()}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,19 +6,21 @@ const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { SyncStatus } from "./SyncStatus"
|
import { SyncStatus } from "./SyncStatus"
|
||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
export function ExportDialog() {
|
export function ExportDialog() {
|
||||||
|
const { theme } = useTheme();
|
||||||
const filename = createSignal("podcast-sync.json")
|
const filename = createSignal("podcast-sync.json")
|
||||||
const format = createSignal<"json" | "xml">("json")
|
const format = createSignal<"json" | "xml">("json")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<text>File:</text>
|
<text fg={theme.text}>File:</text>
|
||||||
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
||||||
</box>
|
</box>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<text>Format:</text>
|
<text fg={theme.text}>Format:</text>
|
||||||
<tab_select
|
<tab_select
|
||||||
options={[
|
options={[
|
||||||
{ name: "JSON", description: "Portable" },
|
{ name: "JSON", description: "Portable" },
|
||||||
@@ -27,8 +29,8 @@ export function ExportDialog() {
|
|||||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box border>
|
<box border borderColor={theme.border}>
|
||||||
<text>Export {format[0]()} to {filename[0]()}</text>
|
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text>
|
||||||
</box>
|
</box>
|
||||||
<SyncStatus />
|
<SyncStatus />
|
||||||
</box>
|
</box>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { detectFormat } from "@/utils/file-detector";
|
import { detectFormat } from "@/utils/file-detector";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
type FilePickerProps = {
|
type FilePickerProps = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -6,6 +7,7 @@ type FilePickerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function FilePicker(props: FilePickerProps) {
|
export function FilePicker(props: FilePickerProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const format = detectFormat(props.value);
|
const format = detectFormat(props.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +18,7 @@ export function FilePicker(props: FilePickerProps) {
|
|||||||
placeholder="/path/to/sync-file.json"
|
placeholder="/path/to/sync-file.json"
|
||||||
style={{ width: 40 }}
|
style={{ width: 40 }}
|
||||||
/>
|
/>
|
||||||
<text>Format: {format}</text>
|
<text fg={theme.text}>Format: {format}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,15 +6,17 @@ const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { FilePicker } from "./FilePicker"
|
import { FilePicker } from "./FilePicker"
|
||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
export function ImportDialog() {
|
export function ImportDialog() {
|
||||||
|
const { theme } = useTheme();
|
||||||
const filePath = createSignal("")
|
const filePath = createSignal("")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border title="Import" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
<box border title="Import" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
<FilePicker value={filePath[0]()} onChange={filePath[1]} />
|
<FilePicker value={filePath[0]()} onChange={filePath[1]} />
|
||||||
<box border>
|
<box border borderColor={theme.border}>
|
||||||
<text>Import selected file</text>
|
<text fg={theme.text}>Import selected file</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
@@ -71,7 +71,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
|||||||
? (currentIndex - 1 + fields.length) % fields.length
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
: (currentIndex + 1) % fields.length;
|
: (currentIndex + 1) % fields.length;
|
||||||
setFocusField(fields[nextIndex]);
|
setFocusField(fields[nextIndex]);
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
} else if (key.name === "return") {
|
||||||
if (focusField() === "submit") {
|
if (focusField() === "submit") {
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
} else if (focusField() === "code" && props.onNavigateToCode) {
|
} else if (focusField() === "code" && props.onNavigateToCode) {
|
||||||
@@ -83,8 +83,8 @@ export function LoginScreen(props: LoginScreenProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border padding={2} gap={1}>
|
<box flexDirection="column" border borderColor={theme.border} padding={2} gap={1}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<strong>Sign In</strong>
|
<strong>Sign In</strong>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
|||||||
|
|
||||||
{/* Email field */}
|
{/* Email field */}
|
||||||
<box flexDirection="column" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg={focusField() === "email" ? theme.primary : undefined}>
|
<text fg={focusField() === "email" ? theme.primary : theme.textMuted}>
|
||||||
Email:
|
Email:
|
||||||
</text>
|
</text>
|
||||||
<input
|
<input
|
||||||
@@ -107,7 +107,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
|||||||
|
|
||||||
{/* Password field */}
|
{/* Password field */}
|
||||||
<box flexDirection="column" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg={focusField() === "password" ? theme.primary : undefined}>
|
<text fg={focusField() === "password" ? theme.primary : theme.textMuted}>
|
||||||
Password:
|
Password:
|
||||||
</text>
|
</text>
|
||||||
<input
|
<input
|
||||||
@@ -126,6 +126,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
|||||||
<box flexDirection="row" gap={2}>
|
<box flexDirection="row" gap={2}>
|
||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
focusField() === "submit" ? theme.primary : undefined
|
focusField() === "submit" ? theme.primary : undefined
|
||||||
@@ -148,6 +149,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
|||||||
<box flexDirection="row" gap={2}>
|
<box flexDirection="row" gap={2}>
|
||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "code" ? theme.primary : undefined}
|
backgroundColor={focusField() === "code" ? theme.primary : undefined}
|
||||||
>
|
>
|
||||||
@@ -158,6 +160,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
|||||||
|
|
||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
|
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
|
||||||
>
|
>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
|
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
interface OAuthPlaceholderProps {
|
interface OAuthPlaceholderProps {
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
@@ -15,6 +16,7 @@ interface OAuthPlaceholderProps {
|
|||||||
type FocusField = "code" | "back";
|
type FocusField = "code" | "back";
|
||||||
|
|
||||||
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
||||||
|
|
||||||
const fields: FocusField[] = ["code", "back"];
|
const fields: FocusField[] = ["code", "back"];
|
||||||
@@ -26,7 +28,7 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
|||||||
? (currentIndex - 1 + fields.length) % fields.length
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
: (currentIndex + 1) % fields.length;
|
: (currentIndex + 1) % fields.length;
|
||||||
setFocusField(fields[nextIndex]);
|
setFocusField(fields[nextIndex]);
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
} else if (key.name === "return") {
|
||||||
if (focusField() === "code" && props.onNavigateToCode) {
|
if (focusField() === "code" && props.onNavigateToCode) {
|
||||||
props.onNavigateToCode();
|
props.onNavigateToCode();
|
||||||
} else if (focusField() === "back" && props.onBack) {
|
} else if (focusField() === "back" && props.onBack) {
|
||||||
@@ -38,23 +40,23 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border padding={2} gap={1}>
|
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<strong>OAuth Authentication</strong>
|
<strong>OAuth Authentication</strong>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
{/* OAuth providers list */}
|
{/* OAuth providers list */}
|
||||||
<text fg="cyan">Available OAuth Providers:</text>
|
<text fg={theme.primary}>Available OAuth Providers:</text>
|
||||||
|
|
||||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||||
{OAUTH_PROVIDERS.map((provider) => (
|
{OAUTH_PROVIDERS.map((provider) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={provider.enabled ? "green" : "gray"}>
|
<text fg={provider.enabled ? theme.success : theme.textMuted}>
|
||||||
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">- {provider.description}</text>
|
<text fg={theme.textMuted}>- {provider.description}</text>
|
||||||
</box>
|
</box>
|
||||||
))}
|
))}
|
||||||
</box>
|
</box>
|
||||||
@@ -62,33 +64,29 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
|||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
{/* Limitation message */}
|
{/* Limitation message */}
|
||||||
<box border padding={1} borderColor="yellow">
|
<box border padding={1} borderColor={theme.warning}>
|
||||||
<text fg="yellow">Terminal Limitations</text>
|
<text fg={theme.warning}>Terminal Limitations</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
||||||
<text fg="gray">{line}</text>
|
<text fg={theme.textMuted}>{line}</text>
|
||||||
))}
|
))}
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
{/* Alternative options */}
|
{/* Alternative options */}
|
||||||
<text fg="cyan">Recommended Alternatives:</text>
|
<text fg={theme.primary}>Recommended Alternatives:</text>
|
||||||
|
|
||||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg="green">[1]</text>
|
<text fg={theme.success}>[1]</text>
|
||||||
<text fg="white">Use a sync code from the web portal</text>
|
<text fg={theme.text}>Use a sync code from the web portal</text>
|
||||||
</box>
|
<text fg={theme.success}>[2]</text>
|
||||||
<box flexDirection="row" gap={1}>
|
<text fg={theme.text}>Use email/password authentication</text>
|
||||||
<text fg="green">[2]</text>
|
<text fg={theme.success}>[3]</text>
|
||||||
<text fg="white">Use email/password authentication</text>
|
<text fg={theme.text}>Use file-based sync (no account needed)</text>
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg="green">[3]</text>
|
|
||||||
<text fg="white">Use file-based sync (no account needed)</text>
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
@@ -99,9 +97,9 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
backgroundColor={focusField() === "code" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusField() === "code" ? "cyan" : undefined}>
|
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
||||||
[C] Enter Sync Code
|
[C] Enter Sync Code
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -109,9 +107,9 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusField() === "back" ? "yellow" : "gray"}>
|
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
||||||
[Esc] Back to Login
|
[Esc] Back to Login
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -119,7 +117,7 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
|||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export function PreferencesPanel() {
|
|||||||
if (key.name === "right" || key.name === "l") {
|
if (key.name === "right" || key.name === "l") {
|
||||||
stepValue(1);
|
stepValue(1);
|
||||||
}
|
}
|
||||||
if (key.name === "space" || key.name === "return" || key.name === "enter") {
|
if (key.name === "space" || key.name === "return") {
|
||||||
toggleValue();
|
toggleValue();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -94,7 +94,7 @@ export function PreferencesPanel() {
|
|||||||
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
|
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
|
||||||
Theme:
|
Theme:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
|
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
|
||||||
</text>
|
</text>
|
||||||
@@ -106,7 +106,7 @@ export function PreferencesPanel() {
|
|||||||
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
|
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
|
||||||
Font Size:
|
Font Size:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text fg={theme.text}>{settings().fontSize}px</text>
|
<text fg={theme.text}>{settings().fontSize}px</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||||
@@ -116,7 +116,7 @@ export function PreferencesPanel() {
|
|||||||
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
|
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
|
||||||
Playback:
|
Playback:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text fg={theme.text}>{settings().playbackSpeed}x</text>
|
<text fg={theme.text}>{settings().playbackSpeed}x</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||||
@@ -128,7 +128,7 @@ export function PreferencesPanel() {
|
|||||||
>
|
>
|
||||||
Show Explicit:
|
Show Explicit:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text
|
<text
|
||||||
fg={preferences().showExplicit ? theme.success : theme.textMuted}
|
fg={preferences().showExplicit ? theme.success : theme.textMuted}
|
||||||
>
|
>
|
||||||
@@ -142,7 +142,7 @@ export function PreferencesPanel() {
|
|||||||
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
|
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
|
||||||
Auto Download:
|
Auto Download:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text
|
<text
|
||||||
fg={preferences().autoDownload ? theme.success : theme.textMuted}
|
fg={preferences().autoDownload ? theme.success : theme.textMuted}
|
||||||
>
|
>
|
||||||
119
src/pages/Settings/SettingsPage.tsx
Normal file
119
src/pages/Settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { createSignal, For, onMount } from "solid-js";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { SourceManager } from "./SourceManager";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PreferencesPanel } from "./PreferencesPanel";
|
||||||
|
import { SyncPanel } from "./SyncPanel";
|
||||||
|
import { VisualizerSettings } from "./VisualizerSettings";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
||||||
|
|
||||||
|
enum SettingsPaneType {
|
||||||
|
SYNC = 1,
|
||||||
|
SOURCES = 2,
|
||||||
|
PREFERENCES = 3,
|
||||||
|
VISUALIZER = 4,
|
||||||
|
ACCOUNT = 5,
|
||||||
|
}
|
||||||
|
export const SettingsPaneCount = 5;
|
||||||
|
|
||||||
|
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
|
||||||
|
{ id: SettingsPaneType.SYNC, label: "Sync" },
|
||||||
|
{ id: SettingsPaneType.SOURCES, label: "Sources" },
|
||||||
|
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" },
|
||||||
|
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
|
||||||
|
{ id: SettingsPaneType.ACCOUNT, label: "Account" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
|
// Helper function to check if a depth is active
|
||||||
|
const isActive = (depth: SettingsPaneType): boolean => {
|
||||||
|
return nav.activeDepth() === depth;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get the current depth as a number
|
||||||
|
const currentDepth = () => nav.activeDepth() as number;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
useKeyboard(
|
||||||
|
(keyEvent: any) => {
|
||||||
|
const isDown = keybind.match("down", keyEvent);
|
||||||
|
const isUp = keybind.match("up", keyEvent);
|
||||||
|
const isCycle = keybind.match("cycle", keyEvent);
|
||||||
|
const isSelect = keybind.match("select", keyEvent);
|
||||||
|
const isInverting = keybind.isInverting(keyEvent);
|
||||||
|
|
||||||
|
// don't handle pane navigation here - unified in App.tsx
|
||||||
|
if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return;
|
||||||
|
|
||||||
|
if (isDown && !isInverting()) {
|
||||||
|
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
|
||||||
|
} else if (isUp && isInverting()) {
|
||||||
|
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
|
||||||
|
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
|
||||||
|
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
|
||||||
|
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
|
||||||
|
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ release: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} height="100%" width="100%">
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={SECTIONS}>
|
||||||
|
{(section, index) => (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={
|
||||||
|
currentDepth() === section.id ? theme.primary : undefined
|
||||||
|
}
|
||||||
|
onMouseDown={() => nav.setActiveDepth(section.id)}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
currentDepth() === section.id ? theme.text : theme.textMuted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
[{index() + 1}] {section.label}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border}
|
||||||
|
flexGrow={1}
|
||||||
|
padding={1}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
{isActive(SettingsPaneType.SYNC) && <SyncPanel />}
|
||||||
|
{isActive(SettingsPaneType.SOURCES) && (
|
||||||
|
<SourceManager focused />
|
||||||
|
)}
|
||||||
|
{isActive(SettingsPaneType.PREFERENCES) && (
|
||||||
|
<PreferencesPanel />
|
||||||
|
)}
|
||||||
|
{isActive(SettingsPaneType.VISUALIZER) && (
|
||||||
|
<VisualizerSettings />
|
||||||
|
)}
|
||||||
|
{isActive(SettingsPaneType.ACCOUNT) && (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={theme.textMuted}>Account</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { useFeedStore } from "@/stores/feed";
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { SourceType } from "@/types/source";
|
import { SourceType } from "@/types/source";
|
||||||
import type { PodcastSource } from "@/types/source";
|
import type { PodcastSource } from "@/types/source";
|
||||||
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
|
||||||
interface SourceManagerProps {
|
interface SourceManagerProps {
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
@@ -62,7 +63,6 @@ export function SourceManager(props: SourceManagerProps) {
|
|||||||
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
|
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
|
||||||
} else if (
|
} else if (
|
||||||
key.name === "return" ||
|
key.name === "return" ||
|
||||||
key.name === "enter" ||
|
|
||||||
key.name === "space"
|
key.name === "space"
|
||||||
) {
|
) {
|
||||||
const source = sources()[selectedIndex()];
|
const source = sources()[selectedIndex()];
|
||||||
@@ -98,7 +98,6 @@ export function SourceManager(props: SourceManagerProps) {
|
|||||||
|
|
||||||
if (focusArea() === "explicit") {
|
if (focusArea() === "explicit") {
|
||||||
if (
|
if (
|
||||||
key.name === "enter" ||
|
|
||||||
key.name === "return" ||
|
key.name === "return" ||
|
||||||
key.name === "space"
|
key.name === "space"
|
||||||
) {
|
) {
|
||||||
@@ -113,7 +112,6 @@ export function SourceManager(props: SourceManagerProps) {
|
|||||||
|
|
||||||
if (focusArea() === "language") {
|
if (focusArea() === "language") {
|
||||||
if (
|
if (
|
||||||
key.name === "enter" ||
|
|
||||||
key.name === "return" ||
|
key.name === "return" ||
|
||||||
key.name === "space"
|
key.name === "space"
|
||||||
) {
|
) {
|
||||||
@@ -169,12 +167,12 @@ export function SourceManager(props: SourceManagerProps) {
|
|||||||
const sourceLanguage = () => selectedSource()?.language || "en_us";
|
const sourceLanguage = () => selectedSource()?.language || "en_us";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border padding={1} gap={1}>
|
<box flexDirection="column" border borderColor={theme.border} padding={1} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<strong>Podcast Sources</strong>
|
<strong>Podcast Sources</strong>
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0} onMouseDown={props.onClose}>
|
<box border borderColor={theme.border} padding={0} onMouseDown={props.onClose}>
|
||||||
<text fg={theme.primary}>[Esc] Close</text>
|
<text fg={theme.primary}>[Esc] Close</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
@@ -182,53 +180,39 @@ export function SourceManager(props: SourceManagerProps) {
|
|||||||
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
||||||
|
|
||||||
{/* Source list */}
|
{/* Source list */}
|
||||||
<box border padding={1} flexDirection="column" gap={1}>
|
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
||||||
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
|
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
|
||||||
Sources:
|
Sources:
|
||||||
</text>
|
</text>
|
||||||
<scrollbox height={6}>
|
<scrollbox height={6}>
|
||||||
<For each={sources()}>
|
<For each={sources()}>
|
||||||
{(source, index) => (
|
{(source, index) => (
|
||||||
<box
|
<SelectableBox
|
||||||
flexDirection="row"
|
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||||
gap={1}
|
flexDirection="row"
|
||||||
padding={0}
|
gap={1}
|
||||||
backgroundColor={
|
padding={0}
|
||||||
focusArea() === "list" && index() === selectedIndex()
|
onMouseDown={() => {
|
||||||
? theme.primary
|
setSelectedIndex(index());
|
||||||
: undefined
|
setFocusArea("list");
|
||||||
}
|
feedStore.toggleSource(source.id);
|
||||||
onMouseDown={() => {
|
}}
|
||||||
setSelectedIndex(index());
|
|
||||||
setFocusArea("list");
|
|
||||||
feedStore.toggleSource(source.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
focusArea() === "list" && index() === selectedIndex()
|
|
||||||
? theme.primary
|
|
||||||
: theme.textMuted
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{focusArea() === "list" && index() === selectedIndex()
|
<SelectableText
|
||||||
? ">"
|
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||||
: " "}
|
primary
|
||||||
</text>
|
>
|
||||||
<text fg={source.enabled ? theme.success : theme.error}>
|
{focusArea() === "list" && index() === selectedIndex()
|
||||||
{source.enabled ? "[x]" : "[ ]"}
|
? ">"
|
||||||
</text>
|
: " "}
|
||||||
<text fg={theme.accent}>{getSourceIcon(source)}</text>
|
</SelectableText>
|
||||||
<text
|
<SelectableText
|
||||||
fg={
|
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||||
focusArea() === "list" && index() === selectedIndex()
|
primary
|
||||||
? theme.text
|
>
|
||||||
: undefined
|
{source.name}
|
||||||
}
|
</SelectableText>
|
||||||
>
|
</SelectableBox>
|
||||||
{source.name}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
@@ -236,111 +220,98 @@ export function SourceManager(props: SourceManagerProps) {
|
|||||||
Space/Enter to toggle, d to delete, a to add
|
Space/Enter to toggle, d to delete, a to add
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* API settings */}
|
{/* API settings */}
|
||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
<text fg={isApiSource() ? theme.textMuted : theme.accent}>
|
<SelectableText selected={() => false} primary={isApiSource()}>
|
||||||
{isApiSource()
|
{isApiSource()
|
||||||
? "API Settings"
|
? "API Settings"
|
||||||
: "API Settings (select an API source)"}
|
: "API Settings (select an API source)"}
|
||||||
</text>
|
</SelectableText>
|
||||||
<box flexDirection="row" gap={2}>
|
<box flexDirection="row" gap={2}>
|
||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={0}
|
borderColor={theme.border}
|
||||||
backgroundColor={
|
padding={0}
|
||||||
focusArea() === "country" ? theme.primary : undefined
|
backgroundColor={
|
||||||
}
|
focusArea() === "country" ? theme.primary : undefined
|
||||||
>
|
}
|
||||||
<text
|
>
|
||||||
fg={focusArea() === "country" ? theme.primary : theme.textMuted}
|
<SelectableText selected={() => false} primary={focusArea() === "country"}>
|
||||||
>
|
Country: {sourceCountry()}
|
||||||
Country: {sourceCountry()}
|
</SelectableText>
|
||||||
</text>
|
</box>
|
||||||
</box>
|
<box
|
||||||
<box
|
border
|
||||||
border
|
borderColor={theme.border}
|
||||||
padding={0}
|
padding={0}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
focusArea() === "language" ? theme.primary : undefined
|
focusArea() === "language" ? theme.primary : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<text
|
<SelectableText selected={() => false} primary={focusArea() === "language"}>
|
||||||
fg={
|
Language:{" "}
|
||||||
focusArea() === "language" ? theme.primary : theme.textMuted
|
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
||||||
}
|
</SelectableText>
|
||||||
>
|
</box>
|
||||||
Language:{" "}
|
<box
|
||||||
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
border
|
||||||
</text>
|
borderColor={theme.border}
|
||||||
</box>
|
padding={0}
|
||||||
<box
|
backgroundColor={
|
||||||
border
|
focusArea() === "explicit" ? theme.primary : undefined
|
||||||
padding={0}
|
}
|
||||||
backgroundColor={
|
>
|
||||||
focusArea() === "explicit" ? theme.primary : undefined
|
<SelectableText selected={() => false} primary={focusArea() === "explicit"}>
|
||||||
}
|
Explicit: {sourceExplicit() ? "Yes" : "No"}
|
||||||
>
|
</SelectableText>
|
||||||
<text
|
</box>
|
||||||
fg={
|
</box>
|
||||||
focusArea() === "explicit" ? theme.primary : theme.textMuted
|
<SelectableText selected={() => false} tertiary>
|
||||||
}
|
Enter/Space to toggle focused setting
|
||||||
>
|
</SelectableText>
|
||||||
Explicit: {sourceExplicit() ? "Yes" : "No"}
|
</box>
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Enter/Space to toggle focused setting
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Add new source form */}
|
{/* Add new source form */}
|
||||||
<box border padding={1} flexDirection="column" gap={1}>
|
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
||||||
<text
|
<SelectableText selected={() => false} primary={focusArea() === "add" || focusArea() === "url"}>
|
||||||
fg={
|
Add New Source:
|
||||||
focusArea() === "add" || focusArea() === "url"
|
</SelectableText>
|
||||||
? theme.primary
|
|
||||||
: theme.textMuted
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add New Source:
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>Name:</text>
|
<SelectableText selected={() => false} tertiary>Name:</SelectableText>
|
||||||
<input
|
<input
|
||||||
value={newSourceName()}
|
value={newSourceName()}
|
||||||
onInput={setNewSourceName}
|
onInput={setNewSourceName}
|
||||||
placeholder="My Custom Feed"
|
placeholder="My Custom Feed"
|
||||||
focused={props.focused && focusArea() === "add"}
|
focused={props.focused && focusArea() === "add"}
|
||||||
width={25}
|
width={25}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>URL:</text>
|
<SelectableText selected={() => false} tertiary>URL:</SelectableText>
|
||||||
<input
|
<input
|
||||||
value={newSourceUrl()}
|
value={newSourceUrl()}
|
||||||
onInput={(v) => {
|
onInput={(v) => {
|
||||||
setNewSourceUrl(v);
|
setNewSourceUrl(v);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com/feed.rss"
|
placeholder="https://example.com/feed.rss"
|
||||||
focused={props.focused && focusArea() === "url"}
|
focused={props.focused && focusArea() === "url"}
|
||||||
width={35}
|
width={35}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box border padding={0} width={15} onMouseDown={handleAddSource}>
|
<box border borderColor={theme.border} padding={0} width={15} onMouseDown={handleAddSource}>
|
||||||
<text fg={theme.success}>[+] Add Source</text>
|
<SelectableText selected={() => false} primary>[+] Add Source</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error() && <text fg={theme.error}>{error()}</text>}
|
{error() && <SelectableText selected={() => false} tertiary>{error()}</SelectableText>}
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to switch sections, Esc to close</text>
|
<SelectableText selected={() => false} tertiary>Tab to switch sections, Esc to close</SelectableText>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
18
src/pages/Settings/SyncError.tsx
Normal file
18
src/pages/Settings/SyncError.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
|
type SyncErrorProps = {
|
||||||
|
message: string
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncError(props: SyncErrorProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<box border title="Error" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
|
<text fg={theme.text}>{props.message}</text>
|
||||||
|
<box border borderColor={theme.border} onMouseDown={props.onRetry}>
|
||||||
|
<text fg={theme.text}>Retry</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,18 +8,20 @@ const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
|||||||
import { ImportDialog } from "./ImportDialog"
|
import { ImportDialog } from "./ImportDialog"
|
||||||
import { ExportDialog } from "./ExportDialog"
|
import { ExportDialog } from "./ExportDialog"
|
||||||
import { SyncStatus } from "./SyncStatus"
|
import { SyncStatus } from "./SyncStatus"
|
||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
export function SyncPanel() {
|
export function SyncPanel() {
|
||||||
|
const { theme } = useTheme();
|
||||||
const mode = createSignal<"import" | "export" | null>(null)
|
const mode = createSignal<"import" | "export" | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<box border onMouseDown={() => mode[1]("import")}>
|
<box border borderColor={theme.border} onMouseDown={() => mode[1]("import")}>
|
||||||
<text>Import</text>
|
<text fg={theme.text}>Import</text>
|
||||||
</box>
|
</box>
|
||||||
<box border onMouseDown={() => mode[1]("export")}>
|
<box border borderColor={theme.border} onMouseDown={() => mode[1]("export")}>
|
||||||
<text>Export</text>
|
<text fg={theme.text}>Export</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
<SyncStatus />
|
<SyncStatus />
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
interface SyncProfileProps {
|
interface SyncProfileProps {
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
@@ -17,6 +18,7 @@ type FocusField = "sync" | "export" | "logout";
|
|||||||
|
|
||||||
export function SyncProfile(props: SyncProfileProps) {
|
export function SyncProfile(props: SyncProfileProps) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const { theme } = useTheme();
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("sync");
|
const [focusField, setFocusField] = createSignal<FocusField>("sync");
|
||||||
const [lastSyncTime] = createSignal<Date | null>(new Date());
|
const [lastSyncTime] = createSignal<Date | null>(new Date());
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ export function SyncProfile(props: SyncProfileProps) {
|
|||||||
? (currentIndex - 1 + fields.length) % fields.length
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
: (currentIndex + 1) % fields.length;
|
: (currentIndex + 1) % fields.length;
|
||||||
setFocusField(fields[nextIndex]);
|
setFocusField(fields[nextIndex]);
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
} else if (key.name === "return") {
|
||||||
if (focusField() === "sync" && props.onManageSync) {
|
if (focusField() === "sync" && props.onManageSync) {
|
||||||
props.onManageSync();
|
props.onManageSync();
|
||||||
} else if (focusField() === "logout" && props.onLogout) {
|
} else if (focusField() === "logout" && props.onLogout) {
|
||||||
@@ -59,8 +61,8 @@ export function SyncProfile(props: SyncProfileProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border padding={2} gap={1}>
|
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<strong>User Profile</strong>
|
<strong>User Profile</strong>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
@@ -77,38 +79,38 @@ export function SyncProfile(props: SyncProfileProps) {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<text fg="cyan">{userInitials()}</text>
|
<text fg={theme.primary}>{userInitials()}</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* User details */}
|
{/* User details */}
|
||||||
<box flexDirection="column" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg="white">{user()?.name || "Guest User"}</text>
|
<text fg={theme.text}>{user()?.name || "Guest User"}</text>
|
||||||
<text fg="gray">{user()?.email || "No email"}</text>
|
<text fg={theme.textMuted}>{user()?.email || "No email"}</text>
|
||||||
<text fg="gray">Joined: {formatDate(user()?.createdAt)}</text>
|
<text fg={theme.textMuted}>Joined: {formatDate(user()?.createdAt)}</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
{/* Sync status section */}
|
{/* Sync status section */}
|
||||||
<box border padding={1} flexDirection="column" gap={0}>
|
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
|
||||||
<text fg="cyan">Sync Status</text>
|
<text fg={theme.primary}>Sync Status</text>
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg="gray">Status:</text>
|
<text fg={theme.textMuted}>Status:</text>
|
||||||
<text fg={user()?.syncEnabled ? "green" : "yellow"}>
|
<text fg={user()?.syncEnabled ? theme.success : theme.warning}>
|
||||||
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg="gray">Last Sync:</text>
|
<text fg={theme.textMuted}>Last Sync:</text>
|
||||||
<text fg="white">{formatDate(lastSyncTime())}</text>
|
<text fg={theme.text}>{formatDate(lastSyncTime())}</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg="gray">Method:</text>
|
<text fg={theme.textMuted}>Method:</text>
|
||||||
<text fg="white">File-based (JSON/XML)</text>
|
<text fg={theme.text}>File-based (JSON/XML)</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
@@ -119,9 +121,9 @@ export function SyncProfile(props: SyncProfileProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "sync" ? "#333" : undefined}
|
backgroundColor={focusField() === "sync" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusField() === "sync" ? "cyan" : undefined}>
|
<text fg={focusField() === "sync" ? theme.primary : undefined}>
|
||||||
[S] Manage Sync
|
[S] Manage Sync
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -129,9 +131,9 @@ export function SyncProfile(props: SyncProfileProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "export" ? "#333" : undefined}
|
backgroundColor={focusField() === "export" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusField() === "export" ? "cyan" : undefined}>
|
<text fg={focusField() === "export" ? theme.primary : undefined}>
|
||||||
[E] Export Data
|
[E] Export Data
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -139,9 +141,9 @@ export function SyncProfile(props: SyncProfileProps) {
|
|||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
backgroundColor={focusField() === "logout" ? "#333" : undefined}
|
backgroundColor={focusField() === "logout" ? theme.backgroundElement : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusField() === "logout" ? "red" : "gray"}>
|
<text fg={focusField() === "logout" ? theme.error : theme.textMuted}>
|
||||||
[L] Logout
|
[L] Logout
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -149,7 +151,7 @@ export function SyncProfile(props: SyncProfileProps) {
|
|||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
|
||||||
<text fg="gray">Tab to navigate, Enter to select</text>
|
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
type SyncProgressProps = {
|
type SyncProgressProps = {
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncProgress(props: SyncProgressProps) {
|
export function SyncProgress(props: SyncProgressProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const width = 30
|
const width = 30
|
||||||
let filled = (props.value / 100) * width
|
let filled = (props.value / 100) * width
|
||||||
filled = filled >= 0 ? filled : 0
|
filled = filled >= 0 ? filled : 0
|
||||||
@@ -18,8 +21,8 @@ export function SyncProgress(props: SyncProgressProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "column" }}>
|
<box style={{ flexDirection: "column" }}>
|
||||||
<text>{bar}</text>
|
<text fg={theme.text}>{bar}</text>
|
||||||
<text>{props.value}%</text>
|
<text fg={theme.text}>{props.value}%</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,12 @@ const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
|||||||
|
|
||||||
import { SyncProgress } from "./SyncProgress"
|
import { SyncProgress } from "./SyncProgress"
|
||||||
import { SyncError } from "./SyncError"
|
import { SyncError } from "./SyncError"
|
||||||
|
import { useTheme } from "@/context/ThemeContext"
|
||||||
|
|
||||||
type SyncState = "idle" | "syncing" | "complete" | "error"
|
type SyncState = "idle" | "syncing" | "complete" | "error"
|
||||||
|
|
||||||
export function SyncStatus() {
|
export function SyncStatus() {
|
||||||
|
const { theme } = useTheme();
|
||||||
const state = createSignal<SyncState>("idle")
|
const state = createSignal<SyncState>("idle")
|
||||||
const message = createSignal("Idle")
|
const message = createSignal("Idle")
|
||||||
const progress = createSignal(0)
|
const progress = createSignal(0)
|
||||||
@@ -35,15 +37,15 @@ export function SyncStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border title="Sync Status" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
<box border title="Sync Status" borderColor={theme.border} style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<text>Status:</text>
|
<text fg={theme.text}>Status:</text>
|
||||||
<text>{message[0]()}</text>
|
<text fg={theme.text}>{message[0]()}</text>
|
||||||
</box>
|
</box>
|
||||||
<SyncProgress value={progress[0]()} />
|
<SyncProgress value={progress[0]()} />
|
||||||
{state[0]() === "error" ? <SyncError message={message[0]()} onRetry={() => toggle()} /> : null}
|
{state[0]() === "error" ? <SyncError message={message[0]()} onRetry={() => toggle()} /> : null}
|
||||||
<box border onMouseDown={toggle}>
|
<box border borderColor={theme.border} onMouseDown={toggle}>
|
||||||
<text>Cycle Status</text>
|
<text fg={theme.text}>Cycle Status</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
@@ -99,7 +99,7 @@ export function VisualizerSettings() {
|
|||||||
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
|
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
|
||||||
Bars:
|
Bars:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text fg={theme.text}>{viz().bars}</text>
|
<text fg={theme.text}>{viz().bars}</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
|
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
|
||||||
@@ -113,7 +113,7 @@ export function VisualizerSettings() {
|
|||||||
>
|
>
|
||||||
Auto Sensitivity:
|
Auto Sensitivity:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text
|
<text
|
||||||
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
|
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
|
||||||
>
|
>
|
||||||
@@ -127,7 +127,7 @@ export function VisualizerSettings() {
|
|||||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
|
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
|
||||||
Noise Reduction:
|
Noise Reduction:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
|
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
|
||||||
@@ -139,7 +139,7 @@ export function VisualizerSettings() {
|
|||||||
>
|
>
|
||||||
Low Cutoff:
|
Low Cutoff:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
|
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
|
||||||
@@ -151,7 +151,7 @@ export function VisualizerSettings() {
|
|||||||
>
|
>
|
||||||
High Cutoff:
|
High Cutoff:
|
||||||
</text>
|
</text>
|
||||||
<box border padding={0}>
|
<box border borderColor={theme.border} padding={0}>
|
||||||
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
|
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
|
||||||
126
src/stores/audio-nav.ts
Normal file
126
src/stores/audio-nav.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Audio navigation store for tracking episode order and position
|
||||||
|
* Persists the current episode context (source type, index, and podcastId)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import {
|
||||||
|
loadAudioNavFromFile,
|
||||||
|
saveAudioNavToFile,
|
||||||
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
|
/** Source type for audio navigation */
|
||||||
|
export enum AudioSource {
|
||||||
|
FEED = "feed",
|
||||||
|
MY_SHOWS = "my_shows",
|
||||||
|
SEARCH = "search",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Audio navigation state */
|
||||||
|
export interface AudioNavState {
|
||||||
|
/** Current source type */
|
||||||
|
source: AudioSource;
|
||||||
|
/** Index of current episode in the ordered list */
|
||||||
|
currentIndex: number;
|
||||||
|
/** Podcast ID for My Shows source */
|
||||||
|
podcastId?: string;
|
||||||
|
/** Timestamp when navigation state was last saved */
|
||||||
|
lastUpdated: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default navigation state */
|
||||||
|
const defaultNavState: AudioNavState = {
|
||||||
|
source: AudioSource.FEED,
|
||||||
|
currentIndex: 0,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Create audio navigation store */
|
||||||
|
export function createAudioNavStore() {
|
||||||
|
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||||
|
|
||||||
|
/** Persist current navigation state to file (fire-and-forget) */
|
||||||
|
function persist(): void {
|
||||||
|
saveAudioNavToFile(navState()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load navigation state from file */
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
const loaded = await loadAudioNavFromFile<AudioNavState>();
|
||||||
|
if (loaded) {
|
||||||
|
setNavState(loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget initialization */
|
||||||
|
init();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Get current navigation state */
|
||||||
|
get state(): AudioNavState {
|
||||||
|
return navState();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update source type */
|
||||||
|
setSource: (source: AudioSource, podcastId?: string) => {
|
||||||
|
setNavState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
source,
|
||||||
|
podcastId,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
}));
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Move to next episode */
|
||||||
|
next: (currentIndex: number) => {
|
||||||
|
setNavState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentIndex,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
}));
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Move to previous episode */
|
||||||
|
prev: (currentIndex: number) => {
|
||||||
|
setNavState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentIndex,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
}));
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reset to default state */
|
||||||
|
reset: () => {
|
||||||
|
setNavState(defaultNavState);
|
||||||
|
persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get current index */
|
||||||
|
getCurrentIndex: (): number => {
|
||||||
|
return navState().currentIndex;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get current source */
|
||||||
|
getSource: (): AudioSource => {
|
||||||
|
return navState().source;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get current podcast ID */
|
||||||
|
getPodcastId: (): string | undefined => {
|
||||||
|
return navState().podcastId;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton instance */
|
||||||
|
let audioNavInstance: ReturnType<typeof createAudioNavStore> | null = null;
|
||||||
|
|
||||||
|
export function useAudioNavStore() {
|
||||||
|
if (!audioNavInstance) {
|
||||||
|
audioNavInstance = createAudioNavStore();
|
||||||
|
}
|
||||||
|
return audioNavInstance;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
|
import { useAuthStore } from "./auth";
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk */
|
/** Max episodes to load per page/chunk */
|
||||||
const MAX_EPISODES_REFRESH = 50;
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
@@ -48,13 +49,6 @@ export function createFeedStore() {
|
|||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
...DEFAULT_SOURCES,
|
...DEFAULT_SOURCES,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const loadedFeeds = await loadFeedsFromFile();
|
|
||||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
|
||||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
|
||||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
|
||||||
})();
|
|
||||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||||
visibility: "all",
|
visibility: "all",
|
||||||
sortBy: "updated" as FeedSortField,
|
sortBy: "updated" as FeedSortField,
|
||||||
@@ -62,15 +56,20 @@ export function createFeedStore() {
|
|||||||
});
|
});
|
||||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
|
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
/** Get filtered and sorted feeds */
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
// Filter by visibility
|
// Filter by visibility
|
||||||
if (f.visibility && f.visibility !== "all") {
|
if (f.visibility && f.visibility !== "all") {
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||||
|
} else if (f.visibility === "all") {
|
||||||
|
// Only show private feeds if authenticated
|
||||||
|
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by source
|
// Filter by source
|
||||||
@@ -148,6 +147,13 @@ export function createFeedStore() {
|
|||||||
return allEpisodes;
|
return allEpisodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Sort episodes in reverse chronological order (newest first) */
|
||||||
|
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||||
|
return [...episodes].sort(
|
||||||
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||||
const fetchEpisodes = async (
|
const fetchEpisodes = async (
|
||||||
feedUrl: string,
|
feedUrl: string,
|
||||||
@@ -164,7 +170,7 @@ export function createFeedStore() {
|
|||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const parsed = parseRSSFeed(xml, feedUrl);
|
const parsed = parseRSSFeed(xml, feedUrl);
|
||||||
const allEpisodes = parsed.episodes;
|
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||||
|
|
||||||
// Cache all parsed episodes for pagination
|
// Cache all parsed episodes for pagination
|
||||||
if (feedId) {
|
if (feedId) {
|
||||||
@@ -264,12 +270,25 @@ export function createFeedStore() {
|
|||||||
|
|
||||||
/** Refresh all feeds */
|
/** Refresh all feeds */
|
||||||
const refreshAllFeeds = async () => {
|
const refreshAllFeeds = async () => {
|
||||||
const currentFeeds = feeds();
|
setIsLoadingFeeds(true);
|
||||||
for (const feed of currentFeeds) {
|
try {
|
||||||
await refreshFeed(feed.id);
|
const currentFeeds = feeds();
|
||||||
|
for (const feed of currentFeeds) {
|
||||||
|
await refreshFeed(feed.id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFeeds(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const loadedFeeds = await loadFeedsFromFile();
|
||||||
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||||
|
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||||
|
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||||
|
await refreshAllFeeds();
|
||||||
|
})();
|
||||||
|
|
||||||
/** Remove a feed */
|
/** Remove a feed */
|
||||||
const removeFeed = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
fullEpisodeCache.delete(feedId);
|
fullEpisodeCache.delete(feedId);
|
||||||
@@ -445,6 +464,7 @@ export function createFeedStore() {
|
|||||||
getFeed,
|
getFeed,
|
||||||
getSelectedFeed,
|
getSelectedFeed,
|
||||||
hasMoreEpisodes,
|
hasMoreEpisodes,
|
||||||
|
isLoadingFeeds,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setFilter,
|
setFilter,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
onExit?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(key.name === "return" || key.name === "enter") &&
|
|
||||||
area === "categories"
|
|
||||||
) {
|
|
||||||
setFocusArea("shows");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category navigation
|
|
||||||
if (area === "categories") {
|
|
||||||
if (key.name === "left" || key.name === "h") {
|
|
||||||
const nextIndex = Math.max(0, categoryIndex() - 1);
|
|
||||||
setCategoryIndex(nextIndex);
|
|
||||||
const cat = DISCOVER_CATEGORIES[nextIndex];
|
|
||||||
if (cat) discoverStore.setSelectedCategory(cat.id);
|
|
||||||
setShowIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "right" || key.name === "l") {
|
|
||||||
const nextIndex = Math.min(
|
|
||||||
DISCOVER_CATEGORIES.length - 1,
|
|
||||||
categoryIndex() + 1,
|
|
||||||
);
|
|
||||||
setCategoryIndex(nextIndex);
|
|
||||||
const cat = DISCOVER_CATEGORIES[nextIndex];
|
|
||||||
if (cat) discoverStore.setSelectedCategory(cat.id);
|
|
||||||
setShowIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "return" || 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") {
|
|
||||||
if (shows.length === 0) return;
|
|
||||||
setShowIndex((i) => Math.min(i + 1, shows.length - 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
if (shows.length === 0) {
|
|
||||||
setFocusArea("categories");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newIndex = showIndex() - 1;
|
|
||||||
if (newIndex < 0) {
|
|
||||||
setFocusArea("categories");
|
|
||||||
} else {
|
|
||||||
setShowIndex(newIndex);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "return" || key.name === "enter") {
|
|
||||||
// Subscribe/unsubscribe
|
|
||||||
const podcast = shows[showIndex()];
|
|
||||||
if (podcast) {
|
|
||||||
discoverStore.toggleSubscription(podcast.id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "escape") {
|
|
||||||
if (area === "shows") {
|
|
||||||
setFocusArea("categories");
|
|
||||||
key.stopPropagation();
|
|
||||||
} else {
|
|
||||||
props.onExit?.();
|
|
||||||
}
|
|
||||||
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 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}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Trending Shows */}
|
|
||||||
<box flexDirection="column" flexGrow={1} border>
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
|
||||||
Trending in{" "}
|
|
||||||
{DISCOVER_CATEGORIES.find(
|
|
||||||
(c) => c.id === discoverStore.selectedCategory(),
|
|
||||||
)?.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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed item component for PodTUI
|
|
||||||
* Displays a single feed/podcast in the list
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Feed, FeedVisibility } from "@/types/feed";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface FeedItemProps {
|
|
||||||
feed: Feed;
|
|
||||||
isSelected: boolean;
|
|
||||||
showEpisodeCount?: boolean;
|
|
||||||
showLastUpdated?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedItem(props: FeedItemProps) {
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return format(date, "MMM d");
|
|
||||||
};
|
|
||||||
|
|
||||||
const episodeCount = () => props.feed.episodes.length;
|
|
||||||
const unplayedCount = () => {
|
|
||||||
// This would be calculated based on episode status
|
|
||||||
return props.feed.episodes.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityIcon = () => {
|
|
||||||
return props.feed.visibility === "public" ? "[P]" : "[*]";
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityColor = () => {
|
|
||||||
return props.feed.visibility === "public" ? "green" : "yellow";
|
|
||||||
};
|
|
||||||
|
|
||||||
const pinnedIndicator = () => {
|
|
||||||
return props.feed.isPinned ? "*" : " ";
|
|
||||||
};
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
if (props.compact) {
|
|
||||||
// Compact single-line view
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
backgroundColor={props.isSelected ? "#333" : undefined}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
>
|
|
||||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
|
||||||
{props.isSelected ? ">" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
|
||||||
<text fg={props.isSelected ? "white" : theme.accent}>
|
|
||||||
{props.feed.customName || props.feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
{props.showEpisodeCount && <text fg="gray">({episodeCount()})</text>}
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full view with details
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
border={props.isSelected}
|
|
||||||
borderColor={props.isSelected ? "cyan" : undefined}
|
|
||||||
backgroundColor={props.isSelected ? "#222" : undefined}
|
|
||||||
padding={1}
|
|
||||||
>
|
|
||||||
{/* Title row */}
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
|
||||||
{props.isSelected ? ">" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
|
||||||
<text fg="yellow">{pinnedIndicator()}</text>
|
|
||||||
<text fg={props.isSelected ? "white" : theme.text}>
|
|
||||||
<strong>
|
|
||||||
{props.feed.customName || props.feed.podcast.title}
|
|
||||||
</strong>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
|
||||||
{props.showEpisodeCount && (
|
|
||||||
<text fg="gray">
|
|
||||||
{episodeCount()} episodes ({unplayedCount()} new)
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
{props.showLastUpdated && (
|
|
||||||
<text fg="gray">Updated: {formatDate(props.feed.lastUpdated)}</text>
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{props.feed.podcast.description && (
|
|
||||||
<box paddingLeft={4} paddingTop={0}>
|
|
||||||
<text fg="gray">
|
|
||||||
{props.feed.podcast.description.slice(0, 60)}
|
|
||||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
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?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
backgroundColor={theme.background}
|
|
||||||
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" : theme.text}>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
|
||||||
import { useAppStore } from "@/stores/app";
|
|
||||||
import type { Episode } from "@/types/episode";
|
|
||||||
|
|
||||||
type PlayerProps = {
|
|
||||||
focused: boolean;
|
|
||||||
episode?: Episode | null;
|
|
||||||
onExit?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SAMPLE_EPISODE: Episode = {
|
|
||||||
id: "sample-ep",
|
|
||||||
podcastId: "sample-podcast",
|
|
||||||
title: "A Tour of the Productive Mind",
|
|
||||||
description: "A short guided session on building creative focus.",
|
|
||||||
audioUrl: "",
|
|
||||||
duration: 2780,
|
|
||||||
pubDate: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Player(props: PlayerProps) {
|
|
||||||
const audio = useAudio();
|
|
||||||
|
|
||||||
// The episode to display — prefer a passed-in episode, then the
|
|
||||||
// currently-playing episode, then fall back to the sample.
|
|
||||||
const episode = () =>
|
|
||||||
props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE;
|
|
||||||
const dur = () => audio.duration() || episode().duration || 1;
|
|
||||||
|
|
||||||
useKeyboard((key: { name: string }) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
if (key.name === "space") {
|
|
||||||
if (audio.currentEpisode()) {
|
|
||||||
audio.togglePlayback();
|
|
||||||
} else {
|
|
||||||
// Nothing loaded yet — start playing the displayed episode
|
|
||||||
const ep = episode();
|
|
||||||
if (ep.audioUrl) {
|
|
||||||
audio.play(ep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "escape") {
|
|
||||||
props.onExit?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "left") {
|
|
||||||
audio.seekRelative(-10);
|
|
||||||
}
|
|
||||||
if (key.name === "right") {
|
|
||||||
audio.seekRelative(10);
|
|
||||||
}
|
|
||||||
if (key.name === "up") {
|
|
||||||
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))));
|
|
||||||
}
|
|
||||||
if (key.name === "down") {
|
|
||||||
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))));
|
|
||||||
}
|
|
||||||
if (key.name === "s") {
|
|
||||||
const next =
|
|
||||||
audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2));
|
|
||||||
audio.setSpeed(next);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const progressPercent = () => {
|
|
||||||
const d = dur();
|
|
||||||
if (d <= 0) return 0;
|
|
||||||
return Math.min(100, Math.round((audio.position() / d) * 100));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
|
||||||
const m = Math.floor(seconds / 60);
|
|
||||||
const s = Math.floor(seconds % 60);
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<text>
|
|
||||||
<strong>Now Playing</strong>
|
|
||||||
</text>
|
|
||||||
<text fg="gray">
|
|
||||||
{formatTime(audio.position())} / {formatTime(dur())} (
|
|
||||||
{progressPercent()}%)
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{audio.error() && <text fg="red">{audio.error()}</text>}
|
|
||||||
|
|
||||||
<box border padding={1} flexDirection="column" gap={1}>
|
|
||||||
<text fg="white">
|
|
||||||
<strong>{episode().title}</strong>
|
|
||||||
</text>
|
|
||||||
<text fg="gray">{episode().description}</text>
|
|
||||||
|
|
||||||
<RealtimeWaveform
|
|
||||||
audioUrl={episode().audioUrl}
|
|
||||||
position={audio.position()}
|
|
||||||
duration={dur()}
|
|
||||||
isPlaying={audio.isPlaying()}
|
|
||||||
speed={audio.speed()}
|
|
||||||
onSeek={(next: number) => audio.seek(next)}
|
|
||||||
visualizerConfig={(() => {
|
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
|
||||||
return {
|
|
||||||
bars: viz.bars,
|
|
||||||
noiseReduction: viz.noiseReduction,
|
|
||||||
lowCutOff: viz.lowCutOff,
|
|
||||||
highCutOff: viz.highCutOff,
|
|
||||||
};
|
|
||||||
})()}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<PlaybackControls
|
|
||||||
isPlaying={audio.isPlaying()}
|
|
||||||
volume={audio.volume()}
|
|
||||||
speed={audio.speed()}
|
|
||||||
backendName={audio.backendName()}
|
|
||||||
hasAudioUrl={!!episode().audioUrl}
|
|
||||||
onToggle={() => {
|
|
||||||
if (audio.currentEpisode()) {
|
|
||||||
audio.togglePlayback();
|
|
||||||
} else {
|
|
||||||
const ep = episode();
|
|
||||||
if (ep.audioUrl) audio.play(ep);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPrev={() => audio.seek(0)}
|
|
||||||
onNext={() => audio.seek(dur())}
|
|
||||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
|
||||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<text fg="gray">
|
|
||||||
Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc
|
|
||||||
back
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Show } from "solid-js";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { SourceBadge } from "./SourceBadge";
|
|
||||||
|
|
||||||
type ResultCardProps = {
|
|
||||||
result: SearchResult;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onSubscribe?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ResultCard(props: ResultCardProps) {
|
|
||||||
const podcast = () => props.result.podcast;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
padding={1}
|
|
||||||
border={props.selected}
|
|
||||||
borderColor={props.selected ? "cyan" : undefined}
|
|
||||||
backgroundColor={props.selected ? "#222" : undefined}
|
|
||||||
onMouseDown={props.onSelect}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<text fg={props.selected ? "cyan" : "white"}>
|
|
||||||
<strong>{podcast().title}</strong>
|
|
||||||
</text>
|
|
||||||
<SourceBadge
|
|
||||||
sourceId={props.result.sourceId}
|
|
||||||
sourceName={props.result.sourceName}
|
|
||||||
sourceType={props.result.sourceType}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
<Show when={podcast().isSubscribed}>
|
|
||||||
<text fg="green">[Subscribed]</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show when={podcast().author}>
|
|
||||||
<text fg="gray">by {podcast().author}</text>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={podcast().description}>
|
|
||||||
{(description) => (
|
|
||||||
<text fg={props.selected ? "white" : "gray"}>
|
|
||||||
{description().length > 120
|
|
||||||
? description().slice(0, 120) + "..."
|
|
||||||
: description()}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={(podcast().categories ?? []).length > 0}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
{(podcast().categories ?? []).slice(0, 3).map((category) => (
|
|
||||||
<text fg="yellow">[{category}]</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!podcast().isSubscribed}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
width={18}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.stopPropagation?.();
|
|
||||||
props.onSubscribe?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg="cyan">[+] Add to Feeds</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* SearchPage component - Main search interface for PodTUI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal, createEffect, 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;
|
|
||||||
onExit?: () => 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);
|
|
||||||
|
|
||||||
// Keep parent informed about input focus state
|
|
||||||
createEffect(() => {
|
|
||||||
const isInputFocused = props.focused && focusArea() === "input";
|
|
||||||
props.onInputFocusChange?.(isInputFocused);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
const query = inputValue().trim();
|
|
||||||
if (query) {
|
|
||||||
await searchStore.search(query);
|
|
||||||
if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
setResultIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
searchStore.markSubscribed(result.podcast.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
|
|
||||||
const area = focusArea();
|
|
||||||
|
|
||||||
// Enter to search from input
|
|
||||||
if ((key.name === "return" || 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");
|
|
||||||
} else if (searchStore.history().length > 0) {
|
|
||||||
setFocusArea("history");
|
|
||||||
}
|
|
||||||
} else if (area === "results") {
|
|
||||||
if (searchStore.history().length > 0) {
|
|
||||||
setFocusArea("history");
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "tab" && key.shift) {
|
|
||||||
if (area === "input") {
|
|
||||||
if (searchStore.history().length > 0) {
|
|
||||||
setFocusArea("history");
|
|
||||||
} else if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
}
|
|
||||||
} else if (area === "history") {
|
|
||||||
if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
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 === "return" || 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 === "return" || key.name === "enter") {
|
|
||||||
const query = history[historyIndex()];
|
|
||||||
if (query) handleHistorySelect(query);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape goes back to input or up one level
|
|
||||||
if (key.name === "escape") {
|
|
||||||
if (area === "input") {
|
|
||||||
props.onExit?.();
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
key.stopPropagation();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "/" focuses search input
|
|
||||||
if (key.name === "/" && area !== "input") {
|
|
||||||
setFocusArea("input");
|
|
||||||
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 fg="gray">Search:</text>
|
|
||||||
<input
|
|
||||||
value={inputValue()}
|
|
||||||
onInput={(value) => {
|
|
||||||
setInputValue(value);
|
|
||||||
}}
|
|
||||||
placeholder="Enter podcast name, topic, or author..."
|
|
||||||
focused={props.focused && focusArea() === "input"}
|
|
||||||
width={50}
|
|
||||||
/>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={handleSearch}
|
|
||||||
>
|
|
||||||
<text fg="cyan">[Enter] Search</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<Show when={searchStore.isSearching()}>
|
|
||||||
<text fg="yellow">Searching...</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={searchStore.error()}>
|
|
||||||
<text fg="red">{searchStore.error()}</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}>
|
|
||||||
<text fg={focusArea() === "results" ? "cyan" : "gray"}>
|
|
||||||
Results ({searchStore.results().length})
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show
|
|
||||||
when={searchStore.results().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={2}>
|
|
||||||
<text fg="gray">
|
|
||||||
{searchStore.query()
|
|
||||||
? "No results found"
|
|
||||||
: "Enter a search term to find podcasts"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SearchResults
|
|
||||||
results={searchStore.results()}
|
|
||||||
selectedIndex={resultIndex()}
|
|
||||||
focused={focusArea() === "results"}
|
|
||||||
onSelect={handleResultSelect}
|
|
||||||
onChange={setResultIndex}
|
|
||||||
isSearching={searchStore.isSearching()}
|
|
||||||
error={searchStore.error()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* History Sidebar */}
|
|
||||||
<box width={30} border>
|
|
||||||
<box padding={1} flexDirection="column">
|
|
||||||
<box paddingBottom={1}>
|
|
||||||
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
|
|
||||||
History
|
|
||||||
</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 fg="gray">[Tab] Switch focus</text>
|
|
||||||
<text fg="gray">[/] Focus search</text>
|
|
||||||
<text fg="gray">[Enter] Select</text>
|
|
||||||
<text fg="gray">[Esc] Up</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { createSignal, For } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { SourceManager } from "./SourceManager";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { PreferencesPanel } from "./PreferencesPanel";
|
|
||||||
import { SyncPanel } from "./SyncPanel";
|
|
||||||
import { VisualizerSettings } from "./VisualizerSettings";
|
|
||||||
|
|
||||||
type SettingsScreenProps = {
|
|
||||||
accountLabel: string;
|
|
||||||
accountStatus: "signed-in" | "signed-out";
|
|
||||||
onOpenAccount?: () => void;
|
|
||||||
onExit?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account";
|
|
||||||
|
|
||||||
const SECTIONS: Array<{ id: SectionId; label: string }> = [
|
|
||||||
{ id: "sync", label: "Sync" },
|
|
||||||
{ id: "sources", label: "Sources" },
|
|
||||||
{ id: "preferences", label: "Preferences" },
|
|
||||||
{ id: "visualizer", label: "Visualizer" },
|
|
||||||
{ id: "account", label: "Account" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsScreen(props: SettingsScreenProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [activeSection, setActiveSection] = createSignal<SectionId>("sync");
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (key.name === "escape") {
|
|
||||||
props.onExit?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const idx = SECTIONS.findIndex((s) => s.id === activeSection());
|
|
||||||
const next = key.shift
|
|
||||||
? (idx - 1 + SECTIONS.length) % SECTIONS.length
|
|
||||||
: (idx + 1) % SECTIONS.length;
|
|
||||||
setActiveSection(SECTIONS[next].id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "1") setActiveSection("sync");
|
|
||||||
if (key.name === "2") setActiveSection("sources");
|
|
||||||
if (key.name === "3") setActiveSection("preferences");
|
|
||||||
if (key.name === "4") setActiveSection("visualizer");
|
|
||||||
if (key.name === "5") setActiveSection("account");
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1} height="100%">
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<text>
|
|
||||||
<strong>Settings</strong>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
[Tab] Switch section | 1-5 jump | Esc up
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<For each={SECTIONS}>
|
|
||||||
{(section, index) => (
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={
|
|
||||||
activeSection() === section.id ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
onMouseDown={() => setActiveSection(section.id)}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
activeSection() === section.id ? theme.text : theme.textMuted
|
|
||||||
}
|
|
||||||
>
|
|
||||||
[{index() + 1}] {section.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
|
|
||||||
{activeSection() === "sync" && <SyncPanel />}
|
|
||||||
{activeSection() === "sources" && <SourceManager focused />}
|
|
||||||
{activeSection() === "preferences" && <PreferencesPanel />}
|
|
||||||
{activeSection() === "visualizer" && <VisualizerSettings />}
|
|
||||||
{activeSection() === "account" && (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Account</text>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<text fg={theme.textMuted}>Status:</text>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
props.accountStatus === "signed-in"
|
|
||||||
? theme.success
|
|
||||||
: theme.warning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{props.accountLabel}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={() => props.onOpenAccount?.()}>
|
|
||||||
<text fg={theme.primary}>[A] Manage Account</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Enter to dive | Esc up</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
type SyncErrorProps = {
|
|
||||||
message: string
|
|
||||||
onRetry: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SyncError(props: SyncErrorProps) {
|
|
||||||
return (
|
|
||||||
<box border title="Error" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
|
||||||
<text>{props.message}</text>
|
|
||||||
<box border onMouseDown={props.onRetry}>
|
|
||||||
<text>Retry</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,10 +16,18 @@ export const BASE_THEME_COLORS: ThemeColors = {
|
|||||||
secondary: "#a9b1d6",
|
secondary: "#a9b1d6",
|
||||||
accent: "#f6c177",
|
accent: "#f6c177",
|
||||||
text: "#e6edf3",
|
text: "#e6edf3",
|
||||||
|
textPrimary: "#e6edf3",
|
||||||
|
textSecondary: "#a9b1d6",
|
||||||
|
textTertiary: "#7d8590",
|
||||||
|
textSelectedPrimary: "#1b1f27",
|
||||||
|
textSelectedSecondary: "#e6edf3",
|
||||||
|
textSelectedTertiary: "#a9b1d6",
|
||||||
muted: "#7d8590",
|
muted: "#7d8590",
|
||||||
warning: "#f0b429",
|
warning: "#f0b429",
|
||||||
error: "#f47067",
|
error: "#f47067",
|
||||||
success: "#3fb950",
|
success: "#3fb950",
|
||||||
|
_hasSelectedListItemText: true,
|
||||||
|
thinkingOpacity: 0.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base layer backgrounds
|
// Base layer backgrounds
|
||||||
@@ -61,16 +69,22 @@ export const THEMES_DESKTOP: DesktopTheme = {
|
|||||||
secondary: "#cba6f7",
|
secondary: "#cba6f7",
|
||||||
accent: "#f9e2af",
|
accent: "#f9e2af",
|
||||||
text: "#cdd6f4",
|
text: "#cdd6f4",
|
||||||
|
textPrimary: "#cdd6f4",
|
||||||
|
textSecondary: "#cba6f7",
|
||||||
|
textTertiary: "#7f849c",
|
||||||
|
textSelectedPrimary: "#1e1e2e",
|
||||||
|
textSelectedSecondary: "#cdd6f4",
|
||||||
|
textSelectedTertiary: "#cba6f7",
|
||||||
muted: "#7f849c",
|
muted: "#7f849c",
|
||||||
warning: "#fab387",
|
warning: "#fab387",
|
||||||
error: "#f38ba8",
|
error: "#f38ba8",
|
||||||
success: "#a6e3a1",
|
success: "#a6e3a1",
|
||||||
layerBackgrounds: {
|
layerBackgrounds: {
|
||||||
layer0: "transparent",
|
layer0: "transparent",
|
||||||
layer1: "#181825",
|
layer1: "#181825",
|
||||||
layer2: "#11111b",
|
layer2: "#11111b",
|
||||||
layer3: "#0a0a0f",
|
layer3: "#0a0a0f",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -82,6 +96,12 @@ export const THEMES_DESKTOP: DesktopTheme = {
|
|||||||
secondary: "#83a598",
|
secondary: "#83a598",
|
||||||
accent: "#fe8019",
|
accent: "#fe8019",
|
||||||
text: "#ebdbb2",
|
text: "#ebdbb2",
|
||||||
|
textPrimary: "#ebdbb2",
|
||||||
|
textSecondary: "#83a598",
|
||||||
|
textTertiary: "#928374",
|
||||||
|
textSelectedPrimary: "#282828",
|
||||||
|
textSelectedSecondary: "#ebdbb2",
|
||||||
|
textSelectedTertiary: "#83a598",
|
||||||
muted: "#928374",
|
muted: "#928374",
|
||||||
warning: "#fabd2f",
|
warning: "#fabd2f",
|
||||||
error: "#fb4934",
|
error: "#fb4934",
|
||||||
@@ -103,6 +123,12 @@ export const THEMES_DESKTOP: DesktopTheme = {
|
|||||||
secondary: "#bb9af7",
|
secondary: "#bb9af7",
|
||||||
accent: "#e0af68",
|
accent: "#e0af68",
|
||||||
text: "#c0caf5",
|
text: "#c0caf5",
|
||||||
|
textPrimary: "#c0caf5",
|
||||||
|
textSecondary: "#bb9af7",
|
||||||
|
textTertiary: "#565f89",
|
||||||
|
textSelectedPrimary: "#1a1b26",
|
||||||
|
textSelectedSecondary: "#c0caf5",
|
||||||
|
textSelectedTertiary: "#bb9af7",
|
||||||
muted: "#565f89",
|
muted: "#565f89",
|
||||||
warning: "#e0af68",
|
warning: "#e0af68",
|
||||||
error: "#f7768e",
|
error: "#f7768e",
|
||||||
@@ -124,6 +150,12 @@ export const THEMES_DESKTOP: DesktopTheme = {
|
|||||||
secondary: "#81a1c1",
|
secondary: "#81a1c1",
|
||||||
accent: "#ebcb8b",
|
accent: "#ebcb8b",
|
||||||
text: "#eceff4",
|
text: "#eceff4",
|
||||||
|
textPrimary: "#eceff4",
|
||||||
|
textSecondary: "#81a1c1",
|
||||||
|
textTertiary: "#4c566a",
|
||||||
|
textSelectedPrimary: "#2e3440",
|
||||||
|
textSelectedSecondary: "#eceff4",
|
||||||
|
textSelectedTertiary: "#81a1c1",
|
||||||
muted: "#4c566a",
|
muted: "#4c566a",
|
||||||
warning: "#ebcb8b",
|
warning: "#ebcb8b",
|
||||||
error: "#bf616a",
|
error: "#bf616a",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export interface FeedFilter {
|
|||||||
sortBy?: FeedSortField
|
sortBy?: FeedSortField
|
||||||
/** Sort direction */
|
/** Sort direction */
|
||||||
sortDirection?: "asc" | "desc"
|
sortDirection?: "asc" | "desc"
|
||||||
|
/** Show private feeds */
|
||||||
|
showPrivate?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feed sort fields */
|
/** Feed sort fields */
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface Podcast {
|
|||||||
lastUpdated: Date
|
lastUpdated: Date
|
||||||
/** Whether the podcast is currently subscribed */
|
/** Whether the podcast is currently subscribed */
|
||||||
isSubscribed: boolean
|
isSubscribed: boolean
|
||||||
|
/** Callback to toggle feed visibility */
|
||||||
|
onToggleVisibility?: (feedId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Podcast with episodes included */
|
/** Podcast with episodes included */
|
||||||
|
|||||||
@@ -23,11 +23,20 @@ export type ThemeColors = {
|
|||||||
secondary: ColorValue;
|
secondary: ColorValue;
|
||||||
accent: ColorValue;
|
accent: ColorValue;
|
||||||
text: ColorValue;
|
text: ColorValue;
|
||||||
|
textPrimary?: ColorValue;
|
||||||
|
textSecondary?: ColorValue;
|
||||||
|
textTertiary?: ColorValue;
|
||||||
|
textSelectedPrimary?: ColorValue;
|
||||||
|
textSelectedSecondary?: ColorValue;
|
||||||
|
textSelectedTertiary?: ColorValue;
|
||||||
muted: ColorValue;
|
muted: ColorValue;
|
||||||
warning: ColorValue;
|
warning: ColorValue;
|
||||||
error: ColorValue;
|
error: ColorValue;
|
||||||
success: ColorValue;
|
success: ColorValue;
|
||||||
layerBackgrounds?: LayerBackgrounds;
|
layerBackgrounds?: LayerBackgrounds;
|
||||||
|
_hasSelectedListItemText?: boolean;
|
||||||
|
thinkingOpacity?: number;
|
||||||
|
selectedListItemText?: ColorValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeVariant = {
|
export type ThemeVariant = {
|
||||||
|
|||||||
@@ -23,4 +23,10 @@ export type ThemeJson = {
|
|||||||
export type ThemeColors = Record<string, RGBA> & {
|
export type ThemeColors = Record<string, RGBA> & {
|
||||||
_hasSelectedListItemText: boolean
|
_hasSelectedListItemText: boolean
|
||||||
thinkingOpacity: number
|
thinkingOpacity: number
|
||||||
|
textPrimary?: ColorValue
|
||||||
|
textSecondary?: ColorValue
|
||||||
|
textTertiary?: ColorValue
|
||||||
|
textSelectedPrimary?: ColorValue
|
||||||
|
textSelectedSecondary?: ColorValue
|
||||||
|
textSelectedTertiary?: ColorValue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,67 +8,72 @@ import {
|
|||||||
type ParentProps,
|
type ParentProps,
|
||||||
For,
|
For,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js";
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
||||||
import { useKeybind } from "../context/KeybindContext"
|
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
|
||||||
import { useDialog } from "./dialog"
|
import { useDialog } from "./dialog";
|
||||||
import { useTheme } from "../context/ThemeContext"
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import type { KeybindsConfig } from "../utils/keybind"
|
import { TextAttributes } from "@opentui/core";
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { emit } from "../utils/event-bus";
|
||||||
import { emit } from "../utils/event-bus"
|
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command option for the command palette.
|
* Command option for the command palette.
|
||||||
*/
|
*/
|
||||||
export type CommandOption = {
|
export type CommandOption = {
|
||||||
/** Display title */
|
/** Display title */
|
||||||
title: string
|
title: string;
|
||||||
/** Unique identifier */
|
/** Unique identifier */
|
||||||
value: string
|
value: string;
|
||||||
/** Description shown below title */
|
/** Description shown below title */
|
||||||
description?: string
|
description?: string;
|
||||||
/** Category for grouping */
|
/** Category for grouping */
|
||||||
category?: string
|
category?: string;
|
||||||
/** Keybind reference */
|
/** Keybind reference */
|
||||||
keybind?: keyof KeybindsConfig
|
keybind?: keyof KeybindsResolved;
|
||||||
/** Whether this command is suggested */
|
/** Whether this command is suggested */
|
||||||
suggested?: boolean
|
suggested?: boolean;
|
||||||
/** Slash command configuration */
|
/** Slash command configuration */
|
||||||
slash?: {
|
slash?: {
|
||||||
name: string
|
name: string;
|
||||||
aliases?: string[]
|
aliases?: string[];
|
||||||
}
|
};
|
||||||
/** Whether to hide from command list */
|
/** Whether to hide from command list */
|
||||||
hidden?: boolean
|
hidden?: boolean;
|
||||||
/** Whether command is enabled */
|
/** Whether command is enabled */
|
||||||
enabled?: boolean
|
enabled?: boolean;
|
||||||
/** Footer text (usually keybind display) */
|
/** Footer text (usually keybind display) */
|
||||||
footer?: string
|
footer?: string;
|
||||||
/** Handler when command is selected */
|
/** Handler when command is selected */
|
||||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void
|
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
type CommandContext = ReturnType<typeof init>
|
type CommandContext = ReturnType<typeof init>;
|
||||||
const ctx = createContext<CommandContext>()
|
const ctx = createContext<CommandContext>();
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
const [registrations, setRegistrations] = createSignal<
|
||||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
Accessor<CommandOption[]>[]
|
||||||
const dialog = useDialog()
|
>([]);
|
||||||
const keybind = useKeybind()
|
const [suspendCount, setSuspendCount] = createSignal(0);
|
||||||
|
const dialog = useDialog();
|
||||||
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
const entries = createMemo(() => {
|
const entries = createMemo(() => {
|
||||||
const all = registrations().flatMap((x) => x())
|
const all = registrations().flatMap((x) => x());
|
||||||
return all.map((x) => ({
|
return all.map((x) => ({
|
||||||
...x,
|
...x,
|
||||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||||
}))
|
}));
|
||||||
})
|
});
|
||||||
|
|
||||||
const isEnabled = (option: CommandOption) => option.enabled !== false
|
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
||||||
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
|
const isVisible = (option: CommandOption) =>
|
||||||
|
isEnabled(option) && !option.hidden;
|
||||||
|
|
||||||
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
|
const visibleOptions = createMemo(() =>
|
||||||
|
entries().filter((option) => isVisible(option)),
|
||||||
|
);
|
||||||
const suggestedOptions = createMemo(() =>
|
const suggestedOptions = createMemo(() =>
|
||||||
visibleOptions()
|
visibleOptions()
|
||||||
.filter((option) => option.suggested)
|
.filter((option) => option.suggested)
|
||||||
@@ -77,23 +82,23 @@ function init() {
|
|||||||
value: `suggested:${option.value}`,
|
value: `suggested:${option.value}`,
|
||||||
category: "Suggested",
|
category: "Suggested",
|
||||||
})),
|
})),
|
||||||
)
|
);
|
||||||
const suspended = () => suspendCount() > 0
|
const suspended = () => suspendCount() > 0;
|
||||||
|
|
||||||
// Handle keybind shortcuts
|
// Handle keybind shortcuts
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (suspended()) return
|
if (suspended()) return;
|
||||||
if (dialog.isOpen) return
|
if (dialog.isOpen) return;
|
||||||
for (const option of entries()) {
|
for (const option of entries()) {
|
||||||
if (!isEnabled(option)) continue
|
if (!isEnabled(option)) continue;
|
||||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||||
evt.preventDefault()
|
evt.preventDefault();
|
||||||
option.onSelect?.(dialog)
|
option.onSelect?.(dialog);
|
||||||
emit("command.execute", { command: option.value })
|
emit("command.execute", { command: option.value });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
/**
|
/**
|
||||||
@@ -102,10 +107,10 @@ function init() {
|
|||||||
trigger(name: string) {
|
trigger(name: string) {
|
||||||
for (const option of entries()) {
|
for (const option of entries()) {
|
||||||
if (option.value === name) {
|
if (option.value === name) {
|
||||||
if (!isEnabled(option)) return
|
if (!isEnabled(option)) return;
|
||||||
option.onSelect?.(dialog)
|
option.onSelect?.(dialog);
|
||||||
emit("command.execute", { command: name })
|
emit("command.execute", { command: name });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -114,186 +119,207 @@ function init() {
|
|||||||
*/
|
*/
|
||||||
slashes() {
|
slashes() {
|
||||||
return visibleOptions().flatMap((option) => {
|
return visibleOptions().flatMap((option) => {
|
||||||
const slash = option.slash
|
const slash = option.slash;
|
||||||
if (!slash) return []
|
if (!slash) return [];
|
||||||
return {
|
return {
|
||||||
display: "/" + slash.name,
|
display: "/" + slash.name,
|
||||||
description: option.description ?? option.title,
|
description: option.description ?? option.title,
|
||||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||||
onSelect: () => result.trigger(option.value),
|
onSelect: () => result.trigger(option.value),
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Enable/disable keybinds temporarily.
|
* Enable/disable keybinds temporarily.
|
||||||
*/
|
*/
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
||||||
},
|
},
|
||||||
suspended,
|
suspended,
|
||||||
/**
|
/**
|
||||||
* Show the command palette dialog.
|
* Show the command palette dialog.
|
||||||
*/
|
*/
|
||||||
show() {
|
show() {
|
||||||
dialog.replace(() => <CommandDialog options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
dialog.replace(() => (
|
||||||
|
<CommandDialog
|
||||||
|
options={visibleOptions()}
|
||||||
|
suggestedOptions={suggestedOptions()}
|
||||||
|
/>
|
||||||
|
));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Register commands. Returns cleanup function.
|
* Register commands. Returns cleanup function.
|
||||||
*/
|
*/
|
||||||
register(cb: () => CommandOption[]) {
|
register(cb: () => CommandOption[]) {
|
||||||
const results = createMemo(cb)
|
const results = createMemo(cb);
|
||||||
setRegistrations((arr) => [results, ...arr])
|
setRegistrations((arr) => [results, ...arr]);
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Get all visible options.
|
* Get all visible options.
|
||||||
*/
|
*/
|
||||||
get options() {
|
get options() {
|
||||||
return visibleOptions()
|
return visibleOptions();
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommandDialog() {
|
export function useCommandDialog() {
|
||||||
const value = useContext(ctx)
|
const value = useContext(ctx);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error("useCommandDialog must be used within a CommandProvider")
|
throw new Error("useCommandDialog must be used within a CommandProvider");
|
||||||
}
|
}
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandProvider(props: ParentProps) {
|
export function CommandProvider(props: ParentProps) {
|
||||||
const value = init()
|
const value = init();
|
||||||
const dialog = useDialog()
|
const dialog = useDialog();
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
// Open command palette on ctrl+p or command_list keybind
|
// Open command palette on ctrl+p or command_list keybind
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (value.suspended()) return
|
if (value.suspended()) return;
|
||||||
if (dialog.isOpen) return
|
if (dialog.isOpen) return;
|
||||||
if (evt.defaultPrevented) return
|
if (evt.defaultPrevented) return;
|
||||||
if (keybind.match("command_list", evt)) {
|
if (keybind.match("command_list", evt)) {
|
||||||
evt.preventDefault()
|
evt.preventDefault();
|
||||||
value.show()
|
value.show();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command palette dialog component.
|
* Command palette dialog component.
|
||||||
*/
|
*/
|
||||||
function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
|
function CommandDialog(props: {
|
||||||
const { theme } = useTheme()
|
options: CommandOption[];
|
||||||
const dialog = useDialog()
|
suggestedOptions: CommandOption[];
|
||||||
const dimensions = useTerminalDimensions()
|
}) {
|
||||||
const [filter, setFilter] = createSignal("")
|
const { theme } = useTheme();
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const dialog = useDialog();
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
const [filter, setFilter] = createSignal("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||||
|
|
||||||
const filteredOptions = createMemo(() => {
|
const filteredOptions = createMemo(() => {
|
||||||
const query = filter().toLowerCase()
|
const query = filter().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return [...props.suggestedOptions, ...props.options]
|
return [...props.suggestedOptions, ...props.options];
|
||||||
}
|
}
|
||||||
return props.options.filter(
|
return props.options.filter(
|
||||||
(option) =>
|
(option) =>
|
||||||
option.title.toLowerCase().includes(query) ||
|
option.title.toLowerCase().includes(query) ||
|
||||||
option.description?.toLowerCase().includes(query) ||
|
option.description?.toLowerCase().includes(query) ||
|
||||||
option.category?.toLowerCase().includes(query)
|
option.category?.toLowerCase().includes(query),
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Reset selection when filter changes
|
// Reset selection when filter changes
|
||||||
createMemo(() => {
|
createMemo(() => {
|
||||||
filter()
|
filter();
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.name === "escape") {
|
if (evt.name === "escape") {
|
||||||
dialog.clear()
|
dialog.clear();
|
||||||
evt.preventDefault()
|
evt.preventDefault();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "return" || evt.name === "enter") {
|
if (evt.name === "return" || evt.name === "enter") {
|
||||||
const option = filteredOptions()[selectedIndex()]
|
const option = filteredOptions()[selectedIndex()];
|
||||||
if (option) {
|
if (option) {
|
||||||
option.onSelect?.(dialog)
|
option.onSelect?.(dialog);
|
||||||
dialog.clear()
|
dialog.clear();
|
||||||
}
|
}
|
||||||
evt.preventDefault()
|
evt.preventDefault();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||||
evt.preventDefault()
|
evt.preventDefault();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1))
|
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
||||||
evt.preventDefault()
|
evt.preventDefault();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle text input
|
// Handle text input
|
||||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
setFilter((f) => f + evt.name)
|
setFilter((f) => f + evt.name);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "backspace") {
|
if (evt.name === "backspace") {
|
||||||
setFilter((f) => f.slice(0, -1))
|
setFilter((f) => f.slice(0, -1));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const maxHeight = Math.floor(dimensions().height * 0.6)
|
const maxHeight = Math.floor(dimensions().height * 0.6);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" padding={1}>
|
<box flexDirection="column" padding={1} borderColor={theme.border}>
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<box marginBottom={1}>
|
<box marginBottom={1}>
|
||||||
<text fg={theme.textMuted}>
|
<text fg={theme.textMuted}>{"> "}</text>
|
||||||
{"> "}
|
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
||||||
</text>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{filter() || "Type to search commands..."}
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Command list */}
|
{/* Command list */}
|
||||||
<box flexDirection="column" maxHeight={maxHeight}>
|
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}>
|
||||||
<For each={filteredOptions().slice(0, 10)}>
|
<For each={filteredOptions().slice(0, 10)}>
|
||||||
{(option, index) => (
|
{(option, index) => (
|
||||||
<box
|
<SelectableBox
|
||||||
backgroundColor={index() === selectedIndex() ? theme.primary : undefined}
|
selected={() => index() === selectedIndex()}
|
||||||
|
flexDirection="column"
|
||||||
padding={1}
|
padding={1}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSelectedIndex(index());
|
||||||
|
const selectedOption = filteredOptions()[index()];
|
||||||
|
if (selectedOption) {
|
||||||
|
selectedOption.onSelect?.(dialog);
|
||||||
|
dialog.clear();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="column" flexGrow={1}>
|
<box flexDirection="column" flexGrow={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<SelectableText
|
||||||
<text
|
selected={() => index() === selectedIndex()}
|
||||||
fg={index() === selectedIndex() ? theme.selectedListItemText : theme.text}
|
primary
|
||||||
attributes={index() === selectedIndex() ? TextAttributes.BOLD : undefined}
|
>
|
||||||
>
|
{option.title}
|
||||||
{option.title}
|
</SelectableText>
|
||||||
</text>
|
<Show when={option.footer}>
|
||||||
<Show when={option.footer}>
|
<SelectableText
|
||||||
<text fg={theme.textMuted}>{option.footer}</text>
|
selected={() => index() === selectedIndex()}
|
||||||
</Show>
|
tertiary
|
||||||
</box>
|
>
|
||||||
<Show when={option.description}>
|
{option.footer}
|
||||||
<text fg={theme.textMuted}>{option.description}</text>
|
</SelectableText>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
<Show when={option.description}>
|
||||||
</box>
|
<SelectableText
|
||||||
|
selected={() => index() === selectedIndex()}
|
||||||
|
tertiary
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</SelectableText>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</SelectableBox>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Show when={filteredOptions().length === 0}>
|
<Show when={filteredOptions().length === 0}>
|
||||||
@@ -303,5 +329,5 @@ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: Comm
|
|||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function Dialog(
|
|||||||
maxWidth={dimensions().width - 2}
|
maxWidth={dimensions().width - 2}
|
||||||
backgroundColor={theme.backgroundPanel}
|
backgroundColor={theme.backgroundPanel}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
|
borderColor={theme.border}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { DEFAULT_THEME } from "../constants/themes";
|
|||||||
|
|
||||||
const APP_STATE_FILE = "app-state.json";
|
const APP_STATE_FILE = "app-state.json";
|
||||||
const PROGRESS_FILE = "progress.json";
|
const PROGRESS_FILE = "progress.json";
|
||||||
|
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||||
|
|
||||||
// --- Defaults ---
|
// --- Defaults ---
|
||||||
|
|
||||||
@@ -119,3 +120,39 @@ export async function saveProgressToFile(
|
|||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AudioNavEntry {
|
||||||
|
source: string;
|
||||||
|
currentIndex: number;
|
||||||
|
podcastId?: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load audio navigation state from JSON file */
|
||||||
|
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
|
||||||
|
const file = Bun.file(filePath);
|
||||||
|
if (!(await file.exists())) return null;
|
||||||
|
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
|
return raw as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save audio navigation state to JSON file */
|
||||||
|
export async function saveAudioNavToFile<T>(
|
||||||
|
data: T,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
|
||||||
|
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
32
src/utils/jsonc.ts
Normal file
32
src/utils/jsonc.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* JSONC parser utility for handling JSON with comments
|
||||||
|
*
|
||||||
|
* JSONC (JSON with Comments) is a superset of JSON that allows single-line
|
||||||
|
* and multi-line comments, which is useful for configuration files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove JSONC comments from a string
|
||||||
|
*/
|
||||||
|
export function stripComments(jsonString: string): string {
|
||||||
|
const comments = [
|
||||||
|
{ pattern: /\/\/.*$/gm, replacement: "" },
|
||||||
|
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = jsonString;
|
||||||
|
|
||||||
|
for (const { pattern, replacement } of comments) {
|
||||||
|
result = result.replace(pattern, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSONC string into a JavaScript object
|
||||||
|
*/
|
||||||
|
export function parseJSONC(jsonString: string): unknown {
|
||||||
|
const stripped = stripComments(jsonString);
|
||||||
|
return JSON.parse(stripped);
|
||||||
|
}
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import type { ParsedKey } from "@opentui/core"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keyboard shortcut parsing and matching utilities.
|
|
||||||
*
|
|
||||||
* Supports key combinations like:
|
|
||||||
* - "ctrl+c" - Control + c
|
|
||||||
* - "alt+x" - Alt + x
|
|
||||||
* - "shift+enter" - Shift + Enter
|
|
||||||
* - "<leader>n" - Leader key followed by n
|
|
||||||
* - "ctrl+shift+p" - Control + Shift + p
|
|
||||||
*/
|
|
||||||
|
|
||||||
export namespace Keybind {
|
|
||||||
export interface Info {
|
|
||||||
key: string
|
|
||||||
ctrl: boolean
|
|
||||||
alt: boolean
|
|
||||||
shift: boolean
|
|
||||||
meta: boolean
|
|
||||||
leader: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a keybind string into a structured Info object.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - "ctrl+c" -> { key: "c", ctrl: true, ... }
|
|
||||||
* - "<leader>n" -> { key: "n", leader: true, ... }
|
|
||||||
* - "alt+shift+x" -> { key: "x", alt: true, shift: true, ... }
|
|
||||||
*/
|
|
||||||
export function parse(input: string): Info[] {
|
|
||||||
if (!input) return []
|
|
||||||
|
|
||||||
// Handle multiple keybinds separated by comma or space
|
|
||||||
const parts = input.split(/[,\s]+/).filter(Boolean)
|
|
||||||
|
|
||||||
return parts.map((part) => {
|
|
||||||
const info: Info = {
|
|
||||||
key: "",
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
meta: false,
|
|
||||||
leader: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for leader key prefix
|
|
||||||
if (part.startsWith("<leader>")) {
|
|
||||||
info.leader = true
|
|
||||||
part = part.substring(8) // Remove "<leader>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by + for modifiers
|
|
||||||
const tokens = part.toLowerCase().split("+")
|
|
||||||
|
|
||||||
for (const token of tokens) {
|
|
||||||
switch (token) {
|
|
||||||
case "ctrl":
|
|
||||||
case "control":
|
|
||||||
info.ctrl = true
|
|
||||||
break
|
|
||||||
case "alt":
|
|
||||||
case "option":
|
|
||||||
info.alt = true
|
|
||||||
break
|
|
||||||
case "shift":
|
|
||||||
info.shift = true
|
|
||||||
break
|
|
||||||
case "meta":
|
|
||||||
case "cmd":
|
|
||||||
case "command":
|
|
||||||
case "win":
|
|
||||||
case "super":
|
|
||||||
info.meta = true
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
// The last non-modifier token is the key
|
|
||||||
info.key = token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a ParsedKey event to a Keybind.Info.
|
|
||||||
*/
|
|
||||||
export function fromParsedKey(evt: ParsedKey, leader: boolean = false): Info {
|
|
||||||
// ParsedKey has ctrl, shift, meta but may not have alt directly
|
|
||||||
// We need to check what properties are available
|
|
||||||
const evtAny = evt as unknown as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
key: evt.name?.toLowerCase() ?? "",
|
|
||||||
ctrl: evt.ctrl ?? false,
|
|
||||||
alt: (evtAny.alt as boolean) ?? false,
|
|
||||||
shift: evt.shift ?? false,
|
|
||||||
meta: evt.meta ?? false,
|
|
||||||
leader,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a keybind matches a parsed key event.
|
|
||||||
*/
|
|
||||||
export function match(keybind: Info, evt: Info): boolean {
|
|
||||||
return (
|
|
||||||
keybind.key === evt.key &&
|
|
||||||
keybind.ctrl === evt.ctrl &&
|
|
||||||
keybind.alt === evt.alt &&
|
|
||||||
keybind.shift === evt.shift &&
|
|
||||||
keybind.meta === evt.meta &&
|
|
||||||
keybind.leader === evt.leader
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a keybind Info to a display string.
|
|
||||||
*/
|
|
||||||
export function toString(info: Info): string {
|
|
||||||
const parts: string[] = []
|
|
||||||
|
|
||||||
if (info.leader) parts.push("<leader>")
|
|
||||||
if (info.ctrl) parts.push("Ctrl")
|
|
||||||
if (info.alt) parts.push("Alt")
|
|
||||||
if (info.shift) parts.push("Shift")
|
|
||||||
if (info.meta) parts.push("Cmd")
|
|
||||||
|
|
||||||
if (info.key) {
|
|
||||||
// Capitalize special keys
|
|
||||||
const displayKey = info.key.length === 1 ? info.key.toUpperCase() : capitalize(info.key)
|
|
||||||
parts.push(displayKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join("+")
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(str: string): string {
|
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default keybindings configuration.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_KEYBINDS = {
|
|
||||||
// Leader key (space by default)
|
|
||||||
leader: "space",
|
|
||||||
|
|
||||||
// Navigation
|
|
||||||
tab_next: "tab",
|
|
||||||
tab_prev: "shift+tab",
|
|
||||||
|
|
||||||
// App commands
|
|
||||||
command_list: "ctrl+p",
|
|
||||||
help: "?",
|
|
||||||
quit: "ctrl+c",
|
|
||||||
|
|
||||||
// Session/content
|
|
||||||
session_new: "<leader>n",
|
|
||||||
session_list: "<leader>s",
|
|
||||||
|
|
||||||
// Theme
|
|
||||||
theme_list: "<leader>t",
|
|
||||||
|
|
||||||
// Player
|
|
||||||
player_play: "space",
|
|
||||||
player_pause: "space",
|
|
||||||
player_next: "n",
|
|
||||||
player_prev: "p",
|
|
||||||
player_seek_forward: "l",
|
|
||||||
player_seek_backward: "h",
|
|
||||||
|
|
||||||
// List navigation
|
|
||||||
list_up: "k",
|
|
||||||
list_down: "j",
|
|
||||||
list_top: "g",
|
|
||||||
list_bottom: "G",
|
|
||||||
list_select: "enter",
|
|
||||||
|
|
||||||
// Search
|
|
||||||
search_focus: "/",
|
|
||||||
search_clear: "escape",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KeybindsConfig = typeof DEFAULT_KEYBINDS
|
|
||||||
90
src/utils/keybinds-persistence.ts
Normal file
90
src/utils/keybinds-persistence.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
|
||||||
|
*
|
||||||
|
* Handles copying keybind.jsonc from package to user config directory
|
||||||
|
* and loading/saving keybind configurations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { copyFile, mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { parseJSONC } from "./jsonc";
|
||||||
|
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
|
||||||
|
import type { KeybindsResolved } from "../context/KeybindContext";
|
||||||
|
|
||||||
|
const KEYBINDS_SOURCE = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"src",
|
||||||
|
"config",
|
||||||
|
"keybind.jsonc",
|
||||||
|
);
|
||||||
|
const KEYBINDS_FILE = "keybinds.jsonc";
|
||||||
|
|
||||||
|
/** Default keybinds from package */
|
||||||
|
const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||||
|
up: ["up", "k"],
|
||||||
|
down: ["down", "j"],
|
||||||
|
left: ["left", "h"],
|
||||||
|
right: ["right", "l"],
|
||||||
|
cycle: ["tab"],
|
||||||
|
dive: ["return"],
|
||||||
|
select: ["return"],
|
||||||
|
out: ["esc"],
|
||||||
|
inverseModifier: "shift",
|
||||||
|
leader: ":",
|
||||||
|
quit: ["<leader>q"],
|
||||||
|
"audio-toggle": ["<leader>p"],
|
||||||
|
"audio-pause": [],
|
||||||
|
"audio-play": [],
|
||||||
|
"audio-next": ["<leader>n"],
|
||||||
|
"audio-prev": ["<leader>l"],
|
||||||
|
"audio-seek-forward": ["<leader>sf"],
|
||||||
|
"audio-seek-backward": ["<leader>sb"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Copy keybind.jsonc to user config directory on first run */
|
||||||
|
export async function copyKeybindsIfNeeded(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
const targetFile = Bun.file(targetPath);
|
||||||
|
if (await targetFile.exists()) return;
|
||||||
|
|
||||||
|
await ensureConfigDir();
|
||||||
|
await copyFile(KEYBINDS_SOURCE, targetPath);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load keybinds from JSONC file */
|
||||||
|
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
|
if (!(await file.exists())) return DEFAULT_KEYBINDS;
|
||||||
|
|
||||||
|
const raw = await file.text();
|
||||||
|
const parsed = parseJSONC(raw);
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
|
||||||
|
|
||||||
|
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_KEYBINDS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save keybinds to JSONC file */
|
||||||
|
export async function saveKeybindsToFile(
|
||||||
|
keybinds: KeybindsResolved,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
|
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/utils/navigation.ts
Normal file
38
src/utils/navigation.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { DiscoverPage, DiscoverPaneCount } from "@/pages/Discover/DiscoverPage";
|
||||||
|
import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage";
|
||||||
|
import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage";
|
||||||
|
import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage";
|
||||||
|
import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
|
||||||
|
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
|
||||||
|
|
||||||
|
export enum DIRECTION {
|
||||||
|
Increment,
|
||||||
|
Decrement,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TABS {
|
||||||
|
FEED = 1,
|
||||||
|
MYSHOWS = 2,
|
||||||
|
DISCOVER = 3,
|
||||||
|
SEARCH = 4,
|
||||||
|
PLAYER = 5,
|
||||||
|
SETTINGS = 6,
|
||||||
|
}
|
||||||
|
export const TabsCount = 6;
|
||||||
|
|
||||||
|
export const LayerGraph = {
|
||||||
|
[TABS.FEED]: FeedPage,
|
||||||
|
[TABS.MYSHOWS]: MyShowsPage,
|
||||||
|
[TABS.DISCOVER]: DiscoverPage,
|
||||||
|
[TABS.SEARCH]: SearchPage,
|
||||||
|
[TABS.PLAYER]: PlayerPage,
|
||||||
|
[TABS.SETTINGS]: SettingsPage,
|
||||||
|
};
|
||||||
|
export const LayerDepths = {
|
||||||
|
[TABS.FEED]: FeedPaneCount,
|
||||||
|
[TABS.MYSHOWS]: MyShowsPaneCount,
|
||||||
|
[TABS.DISCOVER]: DiscoverPaneCount,
|
||||||
|
[TABS.SEARCH]: SearchPaneCount,
|
||||||
|
[TABS.PLAYER]: PlayerPaneCount,
|
||||||
|
[TABS.SETTINGS]: SettingsPaneCount,
|
||||||
|
};
|
||||||
@@ -2,9 +2,10 @@ import type { SyncData } from "../types/sync-json"
|
|||||||
import type { SyncDataXML } from "../types/sync-xml"
|
import type { SyncDataXML } from "../types/sync-xml"
|
||||||
import { validateJSONSync, validateXMLSync } from "./sync-validation"
|
import { validateJSONSync, validateXMLSync } from "./sync-validation"
|
||||||
import { syncFormats } from "../constants/sync-formats"
|
import { syncFormats } from "../constants/sync-formats"
|
||||||
|
import { FeedVisibility } from "../types/feed"
|
||||||
|
|
||||||
export function exportToJSON(data: SyncData): string {
|
export function exportToJSON(data: SyncData): string {
|
||||||
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\n}`
|
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importFromJSON(json: string): SyncData {
|
export function importFromJSON(json: string): SyncData {
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ export function generateSystemTheme(
|
|||||||
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha);
|
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha);
|
||||||
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha);
|
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha);
|
||||||
|
|
||||||
|
// Create darker shades for selected text colors to ensure contrast
|
||||||
|
const darken = (color: RGBA, factor: number = 0.6) => {
|
||||||
|
return RGBA.fromInts(
|
||||||
|
Math.round(color.r * 255 * factor),
|
||||||
|
Math.round(color.g * 255 * factor),
|
||||||
|
Math.round(color.b * 255 * factor)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedPrimary = darken(ansi.cyan, isDark ? 0.4 : 0.6);
|
||||||
|
const selectedSecondary = darken(ansi.magenta, isDark ? 0.4 : 0.6);
|
||||||
|
const selectedTertiary = darken(textMuted, isDark ? 0.5 : 0.5);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: {
|
theme: {
|
||||||
primary: ansi.cyan,
|
primary: ansi.cyan,
|
||||||
@@ -75,6 +88,12 @@ export function generateSystemTheme(
|
|||||||
info: ansi.cyan,
|
info: ansi.cyan,
|
||||||
text: fg,
|
text: fg,
|
||||||
textMuted,
|
textMuted,
|
||||||
|
textPrimary: fg,
|
||||||
|
textSecondary: textMuted,
|
||||||
|
textTertiary: textMuted,
|
||||||
|
textSelectedPrimary: selectedPrimary,
|
||||||
|
textSelectedSecondary: selectedSecondary,
|
||||||
|
textSelectedTertiary: selectedTertiary,
|
||||||
selectedListItemText: bg,
|
selectedListItemText: bg,
|
||||||
background: transparent,
|
background: transparent,
|
||||||
backgroundPanel: grays[2],
|
backgroundPanel: grays[2],
|
||||||
|
|||||||
Reference in New Issue
Block a user