Compare commits
4 Commits
4cee352641
...
624a6ba022
| Author | SHA1 | Date | |
|---|---|---|---|
| 624a6ba022 | |||
| cdabf2c3e0 | |||
| b8549777ba | |||
| 9b1a3585e6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.opencode
|
.opencode
|
||||||
|
opencode
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
|||||||
173
src/App.tsx
173
src/App.tsx
@@ -1,58 +1,75 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import { Layout } from "./components/Layout"
|
import { Layout } from "./components/Layout";
|
||||||
import { Navigation } from "./components/Navigation"
|
import { Navigation } from "./components/Navigation";
|
||||||
import { TabNavigation } from "./components/TabNavigation"
|
import { TabNavigation } from "./components/TabNavigation";
|
||||||
import { SyncPanel } from "./components/SyncPanel"
|
import { FeedList } from "./components/FeedList";
|
||||||
import { FeedList } from "./components/FeedList"
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
import { LoginScreen } from "./components/LoginScreen"
|
import { CodeValidation } from "./components/CodeValidation";
|
||||||
import { CodeValidation } from "./components/CodeValidation"
|
import { OAuthPlaceholder } from "./components/OAuthPlaceholder";
|
||||||
import { OAuthPlaceholder } from "./components/OAuthPlaceholder"
|
import { SyncProfile } from "./components/SyncProfile";
|
||||||
import { SyncProfile } from "./components/SyncProfile"
|
import { SearchPage } from "./components/SearchPage";
|
||||||
import { SearchPage } from "./components/SearchPage"
|
import { DiscoverPage } from "./components/DiscoverPage";
|
||||||
import { DiscoverPage } from "./components/DiscoverPage"
|
import { Player } from "./components/Player";
|
||||||
import { useAuthStore } from "./stores/auth"
|
import { SettingsScreen } from "./components/SettingsScreen";
|
||||||
import { useFeedStore } from "./stores/feed"
|
import { useAuthStore } from "./stores/auth";
|
||||||
import { FeedVisibility } from "./types/feed"
|
import { useFeedStore } from "./stores/feed";
|
||||||
import { useAppKeyboard } from "./hooks/useAppKeyboard"
|
import { useAppStore } from "./stores/app";
|
||||||
import type { TabId } from "./components/Tab"
|
import { FeedVisibility } from "./types/feed";
|
||||||
import type { AuthScreen } from "./types/auth"
|
import { useAppKeyboard } from "./hooks/useAppKeyboard";
|
||||||
|
import type { TabId } from "./components/Tab";
|
||||||
|
import type { AuthScreen } from "./types/auth";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = createSignal<TabId>("discover")
|
const [activeTab, setActiveTab] = createSignal<TabId>("settings");
|
||||||
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login")
|
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
|
||||||
const [showAuthPanel, setShowAuthPanel] = createSignal(false)
|
const [showAuthPanel, setShowAuthPanel] = createSignal(false);
|
||||||
const [inputFocused, setInputFocused] = createSignal(false)
|
const [inputFocused, setInputFocused] = createSignal(false);
|
||||||
const auth = useAuthStore()
|
const [layerDepth, setLayerDepth] = createSignal(0);
|
||||||
const feedStore = useFeedStore()
|
const auth = useAuthStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
// Centralized keyboard handler for all tab navigation and shortcuts
|
// Centralized keyboard handler for all tab navigation and shortcuts
|
||||||
useAppKeyboard({
|
useAppKeyboard({
|
||||||
get activeTab() { return activeTab() },
|
get activeTab() {
|
||||||
|
return activeTab();
|
||||||
|
},
|
||||||
onTabChange: setActiveTab,
|
onTabChange: setActiveTab,
|
||||||
inputFocused: inputFocused(),
|
inputFocused: inputFocused(),
|
||||||
|
navigationEnabled: layerDepth() === 0,
|
||||||
onAction: (action) => {
|
onAction: (action) => {
|
||||||
if (action === "escape") {
|
if (action === "escape") {
|
||||||
setShowAuthPanel(false)
|
if (layerDepth() > 0) {
|
||||||
setInputFocused(false)
|
setLayerDepth(0);
|
||||||
|
setInputFocused(false);
|
||||||
|
} else {
|
||||||
|
setShowAuthPanel(false);
|
||||||
|
setInputFocused(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "enter" && layerDepth() === 0) {
|
||||||
|
setLayerDepth(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const tab = activeTab()
|
const tab = activeTab();
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "feeds":
|
case "feeds":
|
||||||
return (
|
return (
|
||||||
<FeedList
|
<FeedList
|
||||||
focused={true}
|
focused={layerDepth() > 0}
|
||||||
showEpisodeCount={true}
|
showEpisodeCount={true}
|
||||||
showLastUpdated={true}
|
showLastUpdated={true}
|
||||||
|
onFocusChange={() => setLayerDepth(0)}
|
||||||
onOpenFeed={(feed) => {
|
onOpenFeed={(feed) => {
|
||||||
// Would open feed detail view
|
// Would open feed detail view
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
case "settings":
|
case "settings":
|
||||||
// Show auth panel or sync panel based on state
|
// Show auth panel or sync panel based on state
|
||||||
@@ -60,123 +77,117 @@ export function App() {
|
|||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<SyncProfile
|
<SyncProfile
|
||||||
focused={true}
|
focused={layerDepth() > 0}
|
||||||
onLogout={() => {
|
onLogout={() => {
|
||||||
auth.logout()
|
auth.logout();
|
||||||
setShowAuthPanel(false)
|
setShowAuthPanel(false);
|
||||||
}}
|
}}
|
||||||
onManageSync={() => setShowAuthPanel(false)}
|
onManageSync={() => setShowAuthPanel(false)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (authScreen()) {
|
switch (authScreen()) {
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
<CodeValidation
|
<CodeValidation
|
||||||
focused={true}
|
focused={layerDepth() > 0}
|
||||||
onBack={() => setAuthScreen("login")}
|
onBack={() => setAuthScreen("login")}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
case "oauth":
|
case "oauth":
|
||||||
return (
|
return (
|
||||||
<OAuthPlaceholder
|
<OAuthPlaceholder
|
||||||
focused={true}
|
focused={layerDepth() > 0}
|
||||||
onBack={() => setAuthScreen("login")}
|
onBack={() => setAuthScreen("login")}
|
||||||
onNavigateToCode={() => setAuthScreen("code")}
|
onNavigateToCode={() => setAuthScreen("code")}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
case "login":
|
case "login":
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<LoginScreen
|
<LoginScreen
|
||||||
focused={true}
|
focused={layerDepth() > 0}
|
||||||
onNavigateToCode={() => setAuthScreen("code")}
|
onNavigateToCode={() => setAuthScreen("code")}
|
||||||
onNavigateToOAuth={() => setAuthScreen("oauth")}
|
onNavigateToOAuth={() => setAuthScreen("oauth")}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" gap={1}>
|
<SettingsScreen
|
||||||
<SyncPanel />
|
onOpenAccount={() => setShowAuthPanel(true)}
|
||||||
<box height={1} />
|
accountLabel={
|
||||||
<box border padding={1}>
|
auth.isAuthenticated
|
||||||
<box flexDirection="row" gap={2}>
|
? `Signed in as ${auth.user?.email}`
|
||||||
<text fg="gray">Account:</text>
|
: "Not signed in"
|
||||||
{auth.isAuthenticated ? (
|
}
|
||||||
<text fg="green">Signed in as {auth.user?.email}</text>
|
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"}
|
||||||
) : (
|
onExit={() => setLayerDepth(0)}
|
||||||
<text fg="yellow">Not signed in</text>
|
/>
|
||||||
)}
|
);
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
onMouseDown={() => setShowAuthPanel(true)}
|
|
||||||
>
|
|
||||||
<text fg="cyan">
|
|
||||||
{auth.isAuthenticated ? "[A] Account" : "[A] Sign In"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
|
|
||||||
case "discover":
|
case "discover":
|
||||||
return (
|
return (
|
||||||
<DiscoverPage focused={!inputFocused()} />
|
<DiscoverPage
|
||||||
)
|
focused={layerDepth() > 0}
|
||||||
|
onExit={() => setLayerDepth(0)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "search":
|
case "search":
|
||||||
return (
|
return (
|
||||||
<SearchPage
|
<SearchPage
|
||||||
focused={!inputFocused()}
|
focused={layerDepth() > 0}
|
||||||
onInputFocusChange={setInputFocused}
|
onInputFocusChange={setInputFocused}
|
||||||
|
onExit={() => setLayerDepth(0)}
|
||||||
onSubscribe={(result) => {
|
onSubscribe={(result) => {
|
||||||
const feeds = feedStore.feeds()
|
const feeds = feedStore.feeds();
|
||||||
const alreadySubscribed = feeds.some(
|
const alreadySubscribed = feeds.some(
|
||||||
(feed) =>
|
(feed) =>
|
||||||
feed.podcast.id === result.podcast.id ||
|
feed.podcast.id === result.podcast.id ||
|
||||||
feed.podcast.feedUrl === result.podcast.feedUrl
|
feed.podcast.feedUrl === result.podcast.feedUrl,
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!alreadySubscribed) {
|
if (!alreadySubscribed) {
|
||||||
feedStore.addFeed(
|
feedStore.addFeed(
|
||||||
{ ...result.podcast, isSubscribed: true },
|
{ ...result.podcast, isSubscribed: true },
|
||||||
result.sourceId,
|
result.sourceId,
|
||||||
FeedVisibility.PUBLIC
|
FeedVisibility.PUBLIC,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
case "player":
|
case "player":
|
||||||
|
return (
|
||||||
|
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<box border style={{ padding: 2 }}>
|
<box border style={{ padding: 2 }}>
|
||||||
<text>
|
<text>
|
||||||
<strong>{tab}</strong>
|
<strong>{tab}</strong>
|
||||||
<br />
|
<br />
|
||||||
Player - coming in later phases
|
Coming soon
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
|
theme={appStore.resolveTheme()}
|
||||||
header={
|
header={
|
||||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
}
|
}
|
||||||
footer={
|
footer={<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />}
|
||||||
<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<box style={{ padding: 1 }}>{renderContent()}</box>
|
<box style={{ padding: 1 }}>{renderContent()}</box>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
73
src/api/client.ts
Normal file
73
src/api/client.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import type { PodcastSource } from "../types/source"
|
||||||
|
import { parseRSSFeed } from "@/api/rss-parser"
|
||||||
|
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
|
||||||
|
|
||||||
|
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(feedUrl)
|
||||||
|
if (!response.ok) return []
|
||||||
|
const xml = await response.text()
|
||||||
|
return parseRSSFeed(xml, feedUrl).episodes
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchFeeds = async (
|
||||||
|
sourceIds: string[],
|
||||||
|
sources: PodcastSource[]
|
||||||
|
): Promise<Feed[]> => {
|
||||||
|
const active = sources.filter((source) => sourceIds.includes(source.id))
|
||||||
|
const feeds: Feed[] = []
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
active.map(async (source) => {
|
||||||
|
try {
|
||||||
|
if (source.type === "rss") {
|
||||||
|
const rssFeeds = await handleRSSSource(source)
|
||||||
|
feeds.push(...rssFeeds)
|
||||||
|
} else if (source.type === "api") {
|
||||||
|
const apiFeeds = await handleAPISource(source, "")
|
||||||
|
feeds.push(...apiFeeds)
|
||||||
|
} else {
|
||||||
|
const customFeeds = await handleCustomSource(source, "")
|
||||||
|
feeds.push(...customFeeds)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore individual source errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return feeds
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchPodcasts = async (
|
||||||
|
query: string,
|
||||||
|
sources: PodcastSource[]
|
||||||
|
): Promise<Podcast[]> => {
|
||||||
|
const results: Podcast[] = []
|
||||||
|
await Promise.all(
|
||||||
|
sources.map(async (source) => {
|
||||||
|
try {
|
||||||
|
if (source.type === "rss") {
|
||||||
|
const feeds = await handleRSSSource(source)
|
||||||
|
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||||
|
} else if (source.type === "api") {
|
||||||
|
const feeds = await handleAPISource(source, query)
|
||||||
|
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||||
|
} else {
|
||||||
|
const feeds = await handleCustomSource(source, query)
|
||||||
|
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
53
src/api/rss-parser.ts
Normal file
53
src/api/rss-parser.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
|
const getTagValue = (xml: string, tag: string): string => {
|
||||||
|
const match = xml.match(new RegExp(`<${tag}[^>]*>([\s\S]*?)</${tag}>`, "i"))
|
||||||
|
return match?.[1]?.trim() ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeEntities = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
|
||||||
|
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||||
|
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||||
|
const title = decodeEntities(getTagValue(channel, "title")) || "Untitled Podcast"
|
||||||
|
const description = decodeEntities(getTagValue(channel, "description"))
|
||||||
|
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
||||||
|
const lastUpdated = new Date()
|
||||||
|
|
||||||
|
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||||
|
const episodes = items.map((item, index) => {
|
||||||
|
const epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||||
|
const epDescription = decodeEntities(getTagValue(item, "description"))
|
||||||
|
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||||
|
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||||
|
const audioUrl = enclosure?.[1] ?? ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${feedUrl}#${index}`,
|
||||||
|
podcastId: feedUrl,
|
||||||
|
title: epTitle,
|
||||||
|
description: epDescription,
|
||||||
|
audioUrl,
|
||||||
|
duration: 0,
|
||||||
|
pubDate,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: feedUrl,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
author,
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated,
|
||||||
|
isSubscribed: true,
|
||||||
|
episodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/api/source-handler.ts
Normal file
94
src/api/source-handler.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { FeedVisibility } from "../types/feed"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
import type { PodcastSource } from "../types/source"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import { parseRSSFeed } from "./rss-parser"
|
||||||
|
|
||||||
|
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
|
||||||
|
return {
|
||||||
|
id: `${sourceId}-${podcast.id}`,
|
||||||
|
podcast,
|
||||||
|
episodes: [],
|
||||||
|
visibility: FeedVisibility.PUBLIC,
|
||||||
|
sourceId,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isPinned: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
|
||||||
|
if (!source.baseUrl) return []
|
||||||
|
const response = await fetch(source.baseUrl)
|
||||||
|
if (!response.ok) return []
|
||||||
|
const xml = await response.text()
|
||||||
|
const parsed = parseRSSFeed(xml, source.baseUrl)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${source.id}-${parsed.feedUrl}`,
|
||||||
|
podcast: {
|
||||||
|
id: parsed.id,
|
||||||
|
title: parsed.title,
|
||||||
|
description: parsed.description,
|
||||||
|
feedUrl: parsed.feedUrl,
|
||||||
|
author: parsed.author,
|
||||||
|
categories: parsed.categories,
|
||||||
|
lastUpdated: parsed.lastUpdated,
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: parsed.episodes,
|
||||||
|
visibility: FeedVisibility.PUBLIC,
|
||||||
|
sourceId: source.id,
|
||||||
|
lastUpdated: parsed.lastUpdated,
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleAPISource = async (
|
||||||
|
source: PodcastSource,
|
||||||
|
query: string
|
||||||
|
): Promise<Feed[]> => {
|
||||||
|
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
|
||||||
|
url.searchParams.set("term", query || "podcast")
|
||||||
|
url.searchParams.set("media", "podcast")
|
||||||
|
url.searchParams.set("entity", "podcast")
|
||||||
|
url.searchParams.set("country", source.country || "US")
|
||||||
|
url.searchParams.set("lang", source.language || "en_us")
|
||||||
|
|
||||||
|
const response = await fetch(url.toString())
|
||||||
|
if (!response.ok) return []
|
||||||
|
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
|
||||||
|
const results = data.results ?? []
|
||||||
|
|
||||||
|
return results
|
||||||
|
.filter((item) => item.collectionName && item.feedUrl)
|
||||||
|
.map((item) => {
|
||||||
|
const podcast: Podcast = {
|
||||||
|
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
|
||||||
|
title: item.collectionName || "Untitled Podcast",
|
||||||
|
description: item.collectionName || "",
|
||||||
|
feedUrl: item.feedUrl || "",
|
||||||
|
author: item.artistName,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
}
|
||||||
|
return buildFeedFromPodcast(podcast, source.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleCustomSource = async (
|
||||||
|
source: PodcastSource,
|
||||||
|
query: string
|
||||||
|
): Promise<Feed[]> => {
|
||||||
|
if (!query) return []
|
||||||
|
const podcast: Podcast = {
|
||||||
|
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
|
||||||
|
title: `${query} Highlights`,
|
||||||
|
description: `Curated results for ${query}`,
|
||||||
|
feedUrl: source.baseUrl || "",
|
||||||
|
author: source.name,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
}
|
||||||
|
return [buildFeedFromPodcast(podcast, source.id)]
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { TrendingShows } from "./TrendingShows"
|
|||||||
|
|
||||||
type DiscoverPageProps = {
|
type DiscoverPageProps = {
|
||||||
focused: boolean
|
focused: boolean
|
||||||
|
onExit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type FocusArea = "categories" | "shows"
|
type FocusArea = "categories" | "shows"
|
||||||
@@ -36,18 +37,25 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key.name === "enter" && area === "categories") {
|
||||||
|
setFocusArea("shows")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Category navigation
|
// Category navigation
|
||||||
if (area === "categories") {
|
if (area === "categories") {
|
||||||
if (key.name === "left" || key.name === "h") {
|
if (key.name === "left" || key.name === "h") {
|
||||||
setCategoryIndex((i) => Math.max(0, i - 1))
|
const nextIndex = Math.max(0, categoryIndex() - 1)
|
||||||
const cat = DISCOVER_CATEGORIES[categoryIndex()]
|
setCategoryIndex(nextIndex)
|
||||||
|
const cat = DISCOVER_CATEGORIES[nextIndex]
|
||||||
if (cat) discoverStore.setSelectedCategory(cat.id)
|
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||||
setShowIndex(0) // Reset show selection when changing category
|
setShowIndex(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "right" || key.name === "l") {
|
if (key.name === "right" || key.name === "l") {
|
||||||
setCategoryIndex((i) => Math.min(DISCOVER_CATEGORIES.length - 1, i + 1))
|
const nextIndex = Math.min(DISCOVER_CATEGORIES.length - 1, categoryIndex() + 1)
|
||||||
const cat = DISCOVER_CATEGORIES[categoryIndex()]
|
setCategoryIndex(nextIndex)
|
||||||
|
const cat = DISCOVER_CATEGORIES[nextIndex]
|
||||||
if (cat) discoverStore.setSelectedCategory(cat.id)
|
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||||
setShowIndex(0)
|
setShowIndex(0)
|
||||||
return
|
return
|
||||||
@@ -67,10 +75,15 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
if (area === "shows") {
|
if (area === "shows") {
|
||||||
const shows = discoverStore.filteredPodcasts()
|
const shows = discoverStore.filteredPodcasts()
|
||||||
if (key.name === "down" || key.name === "j") {
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
if (shows.length === 0) return
|
||||||
setShowIndex((i) => Math.min(i + 1, shows.length - 1))
|
setShowIndex((i) => Math.min(i + 1, shows.length - 1))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "up" || key.name === "k") {
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
if (shows.length === 0) {
|
||||||
|
setFocusArea("categories")
|
||||||
|
return
|
||||||
|
}
|
||||||
const newIndex = showIndex() - 1
|
const newIndex = showIndex() - 1
|
||||||
if (newIndex < 0) {
|
if (newIndex < 0) {
|
||||||
setFocusArea("categories")
|
setFocusArea("categories")
|
||||||
@@ -89,6 +102,15 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key.name === "escape") {
|
||||||
|
if (area === "shows") {
|
||||||
|
setFocusArea("categories")
|
||||||
|
} else {
|
||||||
|
props.onExit?.()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh with 'r'
|
// Refresh with 'r'
|
||||||
if (key.name === "r") {
|
if (key.name === "r") {
|
||||||
discoverStore.refresh()
|
discoverStore.refresh()
|
||||||
@@ -170,6 +192,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
<text fg="gray">[Tab] Switch focus</text>
|
<text fg="gray">[Tab] Switch focus</text>
|
||||||
<text fg="gray">[j/k] Navigate</text>
|
<text fg="gray">[j/k] Navigate</text>
|
||||||
<text fg="gray">[Enter] Subscribe</text>
|
<text fg="gray">[Enter] Subscribe</text>
|
||||||
|
<text fg="gray">[Esc] Up</text>
|
||||||
<text fg="gray">[R] Refresh</text>
|
<text fg="gray">[R] Refresh</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js"
|
import { createSignal, For, Show } from "solid-js"
|
||||||
|
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"
|
||||||
@@ -72,6 +73,11 @@ export function FeedDetail(props: FeedDetailProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useKeyboard((key) => {
|
||||||
|
if (!props.focused) return
|
||||||
|
handleKeyPress(key)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
{/* Header with back button */}
|
{/* Header with back button */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js"
|
import { createSignal, For, Show } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { FeedItem } from "./FeedItem"
|
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"
|
||||||
@@ -16,6 +17,7 @@ interface FeedListProps {
|
|||||||
showLastUpdated?: boolean
|
showLastUpdated?: boolean
|
||||||
onSelectFeed?: (feed: Feed) => void
|
onSelectFeed?: (feed: Feed) => void
|
||||||
onOpenFeed?: (feed: Feed) => void
|
onOpenFeed?: (feed: Feed) => void
|
||||||
|
onFocusChange?: (focused: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedList(props: FeedListProps) {
|
export function FeedList(props: FeedListProps) {
|
||||||
@@ -25,6 +27,10 @@ export function FeedList(props: FeedListProps) {
|
|||||||
const filteredFeeds = () => feedStore.getFilteredFeeds()
|
const filteredFeeds = () => feedStore.getFilteredFeeds()
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string }) => {
|
const handleKeyPress = (key: { name: string }) => {
|
||||||
|
if (key.name === "escape") {
|
||||||
|
props.onFocusChange?.(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
const feeds = filteredFeeds()
|
const feeds = filteredFeeds()
|
||||||
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
if (key.name === "up" || key.name === "k") {
|
||||||
@@ -65,6 +71,11 @@ export function FeedList(props: FeedListProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useKeyboard((key) => {
|
||||||
|
if (!props.focused) return
|
||||||
|
handleKeyPress(key)
|
||||||
|
})
|
||||||
|
|
||||||
const cycleVisibilityFilter = () => {
|
const cycleVisibilityFilter = () => {
|
||||||
const current = feedStore.filter().visibility
|
const current = feedStore.filter().visibility
|
||||||
let next: FeedVisibility | "all"
|
let next: FeedVisibility | "all"
|
||||||
@@ -174,7 +185,7 @@ export function FeedList(props: FeedListProps) {
|
|||||||
{/* Navigation help */}
|
{/* Navigation help */}
|
||||||
<box paddingTop={0}>
|
<box paddingTop={0}>
|
||||||
<text fg="gray">
|
<text fg="gray">
|
||||||
j/k navigate | Enter open | p pin | f filter | s sort | Click to select
|
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
|
import type { ThemeColors } from "../types/settings"
|
||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
header?: JSX.Element
|
header?: JSX.Element
|
||||||
footer?: JSX.Element
|
footer?: JSX.Element
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
|
theme?: ThemeColors
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Layout(props: LayoutProps) {
|
export function Layout(props: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "column", width: "100%", height: "100%" }}>
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
backgroundColor={props.theme?.background}
|
||||||
|
>
|
||||||
{props.header ? <box style={{ height: 3 }}>{props.header}</box> : <text></text>}
|
{props.header ? <box style={{ height: 3 }}>{props.header}</box> : <text></text>}
|
||||||
<box style={{ flexGrow: 1 }}>{props.children}</box>
|
<box style={{ flexGrow: 1 }}>{props.children}</box>
|
||||||
{props.footer ? <box style={{ height: 1 }}>{props.footer}</box> : <text></text>}
|
{props.footer ? <box style={{ height: 1 }}>{props.footer}</box> : <text></text>}
|
||||||
|
|||||||
34
src/components/PlaybackControls.tsx
Normal file
34
src/components/PlaybackControls.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
type PlaybackControlsProps = {
|
||||||
|
isPlaying: boolean
|
||||||
|
volume: number
|
||||||
|
speed: number
|
||||||
|
onToggle: () => void
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onVolumeChange: (value: number) => void
|
||||||
|
onSpeedChange: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center" border padding={1}>
|
||||||
|
<box border padding={0} onMouseDown={props.onPrev}>
|
||||||
|
<text fg="cyan">[Prev]</text>
|
||||||
|
</box>
|
||||||
|
<box border padding={0} onMouseDown={props.onToggle}>
|
||||||
|
<text fg="cyan">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||||
|
</box>
|
||||||
|
<box border padding={0} onMouseDown={props.onNext}>
|
||||||
|
<text fg="cyan">[Next]</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
|
<text fg="gray">Vol</text>
|
||||||
|
<text fg="white">{Math.round(props.volume * 100)}%</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
|
<text fg="gray">Speed</text>
|
||||||
|
<text fg="white">{props.speed}x</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/components/Player.tsx
Normal file
114
src/components/Player.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { PlaybackControls } from "./PlaybackControls"
|
||||||
|
import { Waveform } from "./Waveform"
|
||||||
|
import { createWaveform } from "../utils/waveform"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
|
type PlayerProps = {
|
||||||
|
focused: boolean
|
||||||
|
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 [isPlaying, setIsPlaying] = createSignal(false)
|
||||||
|
const [position, setPosition] = createSignal(0)
|
||||||
|
const [volume, setVolume] = createSignal(0.7)
|
||||||
|
const [speed, setSpeed] = createSignal(1)
|
||||||
|
|
||||||
|
const waveform = () => createWaveform(64)
|
||||||
|
|
||||||
|
useKeyboard((key: { name: string }) => {
|
||||||
|
if (!props.focused) return
|
||||||
|
if (key.name === "space") {
|
||||||
|
setIsPlaying((value: boolean) => !value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "escape") {
|
||||||
|
props.onExit?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "left") {
|
||||||
|
setPosition((value: number) => Math.max(0, value - 10))
|
||||||
|
}
|
||||||
|
if (key.name === "right") {
|
||||||
|
setPosition((value: number) => Math.min(SAMPLE_EPISODE.duration, value + 10))
|
||||||
|
}
|
||||||
|
if (key.name === "up") {
|
||||||
|
setVolume((value: number) => Math.min(1, Number((value + 0.05).toFixed(2))))
|
||||||
|
}
|
||||||
|
if (key.name === "down") {
|
||||||
|
setVolume((value: number) => Math.max(0, Number((value - 0.05).toFixed(2))))
|
||||||
|
}
|
||||||
|
if (key.name === "s") {
|
||||||
|
setSpeed((value: number) => (value >= 2 ? 0.5 : Number((value + 0.25).toFixed(2))))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressPercent = () => Math.round((position() / SAMPLE_EPISODE.duration) * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text>
|
||||||
|
<strong>Now Playing</strong>
|
||||||
|
</text>
|
||||||
|
<text fg="gray">
|
||||||
|
Episode {Math.floor(position() / 60)}:{String(Math.floor(position() % 60)).padStart(2, "0")}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box border padding={1} flexDirection="column" gap={1}>
|
||||||
|
<text fg="white">
|
||||||
|
<strong>{SAMPLE_EPISODE.title}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg="gray">{SAMPLE_EPISODE.description}</text>
|
||||||
|
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg="gray">Progress:</text>
|
||||||
|
<box flexGrow={1} height={1} backgroundColor="#2a2f3a">
|
||||||
|
<box
|
||||||
|
width={`${progressPercent()}%`}
|
||||||
|
height={1}
|
||||||
|
backgroundColor={isPlaying() ? "#6fa8ff" : "#7d8590"}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<text fg="gray">{progressPercent()}%</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Waveform
|
||||||
|
data={waveform()}
|
||||||
|
position={position()}
|
||||||
|
duration={SAMPLE_EPISODE.duration}
|
||||||
|
isPlaying={isPlaying()}
|
||||||
|
onSeek={(next: number) => setPosition(next)}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<PlaybackControls
|
||||||
|
isPlaying={isPlaying()}
|
||||||
|
volume={volume()}
|
||||||
|
speed={speed()}
|
||||||
|
onToggle={() => setIsPlaying((value: boolean) => !value)}
|
||||||
|
onPrev={() => setPosition(0)}
|
||||||
|
onNext={() => setPosition(SAMPLE_EPISODE.duration)}
|
||||||
|
onSpeedChange={setSpeed}
|
||||||
|
onVolumeChange={setVolume}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text fg="gray">Enter dive | Esc up | Space play/pause | Left/Right seek</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/components/PreferencesPanel.tsx
Normal file
130
src/components/PreferencesPanel.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { useAppStore } from "../stores/app"
|
||||||
|
import type { ThemeName } from "../types/settings"
|
||||||
|
|
||||||
|
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto"
|
||||||
|
|
||||||
|
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||||
|
{ value: "system", label: "System" },
|
||||||
|
{ value: "catppuccin", label: "Catppuccin" },
|
||||||
|
{ value: "gruvbox", label: "Gruvbox" },
|
||||||
|
{ value: "tokyo", label: "Tokyo" },
|
||||||
|
{ value: "nord", label: "Nord" },
|
||||||
|
{ value: "custom", label: "Custom" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PreferencesPanel() {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const [focusField, setFocusField] = createSignal<FocusField>("theme")
|
||||||
|
|
||||||
|
const settings = () => appStore.state().settings
|
||||||
|
const preferences = () => appStore.state().preferences
|
||||||
|
|
||||||
|
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const fields: FocusField[] = ["theme", "font", "speed", "explicit", "auto"]
|
||||||
|
const idx = fields.indexOf(focusField())
|
||||||
|
const next = key.shift
|
||||||
|
? (idx - 1 + fields.length) % fields.length
|
||||||
|
: (idx + 1) % fields.length
|
||||||
|
setFocusField(fields[next])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "left" || key.name === "h") {
|
||||||
|
stepValue(-1)
|
||||||
|
}
|
||||||
|
if (key.name === "right" || key.name === "l") {
|
||||||
|
stepValue(1)
|
||||||
|
}
|
||||||
|
if (key.name === "space" || key.name === "enter") {
|
||||||
|
toggleValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepValue = (delta: number) => {
|
||||||
|
const field = focusField()
|
||||||
|
if (field === "theme") {
|
||||||
|
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme)
|
||||||
|
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length
|
||||||
|
appStore.setTheme(THEME_LABELS[next].value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (field === "font") {
|
||||||
|
const next = Math.min(20, Math.max(10, settings().fontSize + delta))
|
||||||
|
appStore.updateSettings({ fontSize: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (field === "speed") {
|
||||||
|
const next = Math.min(2, Math.max(0.5, settings().playbackSpeed + delta * 0.1))
|
||||||
|
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleValue = () => {
|
||||||
|
const field = focusField()
|
||||||
|
if (field === "explicit") {
|
||||||
|
appStore.updatePreferences({ showExplicit: !preferences().showExplicit })
|
||||||
|
}
|
||||||
|
if (field === "auto") {
|
||||||
|
appStore.updatePreferences({ autoDownload: !preferences().autoDownload })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboard(handleKey)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg="gray">Preferences</text>
|
||||||
|
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={focusField() === "theme" ? "cyan" : "gray"}>Theme:</text>
|
||||||
|
<box border padding={0}>
|
||||||
|
<text fg="white">{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text>
|
||||||
|
</box>
|
||||||
|
<text fg="gray">[Left/Right]</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={focusField() === "font" ? "cyan" : "gray"}>Font Size:</text>
|
||||||
|
<box border padding={0}>
|
||||||
|
<text fg="white">{settings().fontSize}px</text>
|
||||||
|
</box>
|
||||||
|
<text fg="gray">[Left/Right]</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={focusField() === "speed" ? "cyan" : "gray"}>Playback:</text>
|
||||||
|
<box border padding={0}>
|
||||||
|
<text fg="white">{settings().playbackSpeed}x</text>
|
||||||
|
</box>
|
||||||
|
<text fg="gray">[Left/Right]</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={focusField() === "explicit" ? "cyan" : "gray"}>Show Explicit:</text>
|
||||||
|
<box border padding={0}>
|
||||||
|
<text fg={preferences().showExplicit ? "green" : "gray"}>
|
||||||
|
{preferences().showExplicit ? "On" : "Off"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<text fg="gray">[Space]</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={focusField() === "auto" ? "cyan" : "gray"}>Auto Download:</text>
|
||||||
|
<box border padding={0}>
|
||||||
|
<text fg={preferences().autoDownload ? "green" : "gray"}>
|
||||||
|
{preferences().autoDownload ? "On" : "Off"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<text fg="gray">[Space]</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<text fg="gray">Tab to move focus, Left/Right to adjust</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type SearchPageProps = {
|
|||||||
focused: boolean
|
focused: boolean
|
||||||
onSubscribe?: (result: SearchResult) => void
|
onSubscribe?: (result: SearchResult) => void
|
||||||
onInputFocusChange?: (focused: boolean) => void
|
onInputFocusChange?: (focused: boolean) => void
|
||||||
|
onExit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type FocusArea = "input" | "results" | "history"
|
type FocusArea = "input" | "results" | "history"
|
||||||
@@ -34,6 +35,9 @@ export function SearchPage(props: SearchPageProps) {
|
|||||||
props.onInputFocusChange?.(false)
|
props.onInputFocusChange?.(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (props.focused && focusArea() === "input") {
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHistorySelect = async (query: string) => {
|
const handleHistorySelect = async (query: string) => {
|
||||||
@@ -144,10 +148,14 @@ export function SearchPage(props: SearchPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape goes back to input
|
// Escape goes back to input or up one level
|
||||||
if (key.name === "escape") {
|
if (key.name === "escape") {
|
||||||
setFocusArea("input")
|
if (area === "input") {
|
||||||
props.onInputFocusChange?.(true)
|
props.onExit?.()
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +269,7 @@ export function SearchPage(props: SearchPageProps) {
|
|||||||
<text fg="gray">[Tab] Switch focus</text>
|
<text fg="gray">[Tab] Switch focus</text>
|
||||||
<text fg="gray">[/] Focus search</text>
|
<text fg="gray">[/] Focus search</text>
|
||||||
<text fg="gray">[Enter] Select</text>
|
<text fg="gray">[Enter] Select</text>
|
||||||
<text fg="gray">[Esc] Back to search</text>
|
<text fg="gray">[Esc] Up</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
94
src/components/SettingsScreen.tsx
Normal file
94
src/components/SettingsScreen.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { SourceManager } from "./SourceManager"
|
||||||
|
import { PreferencesPanel } from "./PreferencesPanel"
|
||||||
|
import { SyncPanel } from "./SyncPanel"
|
||||||
|
|
||||||
|
type SettingsScreenProps = {
|
||||||
|
accountLabel: string
|
||||||
|
accountStatus: "signed-in" | "signed-out"
|
||||||
|
onOpenAccount?: () => void
|
||||||
|
onExit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionId = "sync" | "sources" | "preferences" | "account"
|
||||||
|
|
||||||
|
const SECTIONS: Array<{ id: SectionId; label: string }> = [
|
||||||
|
{ id: "sync", label: "Sync" },
|
||||||
|
{ id: "sources", label: "Sources" },
|
||||||
|
{ id: "preferences", label: "Preferences" },
|
||||||
|
{ id: "account", label: "Account" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingsScreen(props: SettingsScreenProps) {
|
||||||
|
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("account")
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} height="100%">
|
||||||
|
<box flexDirection="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<text>
|
||||||
|
<strong>Settings</strong>
|
||||||
|
</text>
|
||||||
|
<text fg="gray">[Tab] Switch section | 1-4 jump | Esc up</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
{SECTIONS.map((section, index) => (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={activeSection() === section.id ? "#2b303b" : undefined}
|
||||||
|
onMouseDown={() => setActiveSection(section.id)}
|
||||||
|
>
|
||||||
|
<text fg={activeSection() === section.id ? "cyan" : "gray"}>
|
||||||
|
[{index + 1}] {section.label}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
|
||||||
|
{activeSection() === "sync" && <SyncPanel />}
|
||||||
|
{activeSection() === "sources" && <SourceManager focused />}
|
||||||
|
{activeSection() === "preferences" && <PreferencesPanel />}
|
||||||
|
{activeSection() === "account" && (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg="gray">Account</text>
|
||||||
|
<box flexDirection="row" gap={2} alignItems="center">
|
||||||
|
<text fg="gray">Status:</text>
|
||||||
|
<text fg={props.accountStatus === "signed-in" ? "green" : "yellow"}>
|
||||||
|
{props.accountLabel}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box border padding={0} onMouseDown={() => props.onOpenAccount?.()}>
|
||||||
|
<text fg="cyan">[A] Manage Account</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<text fg="gray">Enter to dive | Esc up</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/components/Waveform.tsx
Normal file
52
src/components/Waveform.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
type WaveformProps = {
|
||||||
|
data: number[]
|
||||||
|
position: number
|
||||||
|
duration: number
|
||||||
|
isPlaying: boolean
|
||||||
|
onSeek?: (next: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const bars = [".", "-", "~", "=", "#"]
|
||||||
|
|
||||||
|
export function Waveform(props: WaveformProps) {
|
||||||
|
const playedRatio = () => (props.duration === 0 ? 0 : props.position / props.duration)
|
||||||
|
|
||||||
|
const renderLine = () => {
|
||||||
|
const playedCount = Math.floor(props.data.length * playedRatio())
|
||||||
|
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
|
||||||
|
const futureColor = "#3b4252"
|
||||||
|
const played = props.data
|
||||||
|
.map((value, index) =>
|
||||||
|
index <= playedCount
|
||||||
|
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
|
||||||
|
: ""
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
const upcoming = props.data
|
||||||
|
.map((value, index) =>
|
||||||
|
index > playedCount
|
||||||
|
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
|
||||||
|
: ""
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={0}>
|
||||||
|
<text fg={playedColor}>{played || " "}</text>
|
||||||
|
<text fg={futureColor}>{upcoming || " "}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (event: { x: number }) => {
|
||||||
|
const ratio = props.data.length === 0 ? 0 : event.x / props.data.length
|
||||||
|
const next = Math.max(0, Math.min(props.duration, Math.round(props.duration * ratio)))
|
||||||
|
props.onSeek?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box border padding={1} onMouseDown={handleClick}>
|
||||||
|
{renderLine()}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/constants/themes.ts
Normal file
67
src/constants/themes.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ThemeColors, ThemeName } from "../types/settings"
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemeColors = {
|
||||||
|
background: "transparent",
|
||||||
|
surface: "#1b1f27",
|
||||||
|
primary: "#6fa8ff",
|
||||||
|
secondary: "#a9b1d6",
|
||||||
|
accent: "#f6c177",
|
||||||
|
text: "#e6edf3",
|
||||||
|
muted: "#7d8590",
|
||||||
|
warning: "#f0b429",
|
||||||
|
error: "#f47067",
|
||||||
|
success: "#3fb950",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEMES: Record<ThemeName, ThemeColors> = {
|
||||||
|
system: DEFAULT_THEME,
|
||||||
|
catppuccin: {
|
||||||
|
background: "transparent",
|
||||||
|
surface: "#1e1e2e",
|
||||||
|
primary: "#89b4fa",
|
||||||
|
secondary: "#cba6f7",
|
||||||
|
accent: "#f9e2af",
|
||||||
|
text: "#cdd6f4",
|
||||||
|
muted: "#7f849c",
|
||||||
|
warning: "#fab387",
|
||||||
|
error: "#f38ba8",
|
||||||
|
success: "#a6e3a1",
|
||||||
|
},
|
||||||
|
gruvbox: {
|
||||||
|
background: "transparent",
|
||||||
|
surface: "#282828",
|
||||||
|
primary: "#fabd2f",
|
||||||
|
secondary: "#83a598",
|
||||||
|
accent: "#fe8019",
|
||||||
|
text: "#ebdbb2",
|
||||||
|
muted: "#928374",
|
||||||
|
warning: "#fabd2f",
|
||||||
|
error: "#fb4934",
|
||||||
|
success: "#b8bb26",
|
||||||
|
},
|
||||||
|
tokyo: {
|
||||||
|
background: "transparent",
|
||||||
|
surface: "#1a1b26",
|
||||||
|
primary: "#7aa2f7",
|
||||||
|
secondary: "#bb9af7",
|
||||||
|
accent: "#e0af68",
|
||||||
|
text: "#c0caf5",
|
||||||
|
muted: "#565f89",
|
||||||
|
warning: "#e0af68",
|
||||||
|
error: "#f7768e",
|
||||||
|
success: "#9ece6a",
|
||||||
|
},
|
||||||
|
nord: {
|
||||||
|
background: "transparent",
|
||||||
|
surface: "#2e3440",
|
||||||
|
primary: "#88c0d0",
|
||||||
|
secondary: "#81a1c1",
|
||||||
|
accent: "#ebcb8b",
|
||||||
|
text: "#eceff4",
|
||||||
|
muted: "#4c566a",
|
||||||
|
warning: "#ebcb8b",
|
||||||
|
error: "#bf616a",
|
||||||
|
success: "#a3be8c",
|
||||||
|
},
|
||||||
|
custom: DEFAULT_THEME,
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type ShortcutOptions = {
|
|||||||
onTabChange: (tab: TabId) => void
|
onTabChange: (tab: TabId) => void
|
||||||
onAction?: (action: string) => void
|
onAction?: (action: string) => void
|
||||||
inputFocused?: boolean
|
inputFocused?: boolean
|
||||||
|
navigationEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppKeyboard(options: ShortcutOptions) {
|
export function useAppKeyboard(options: ShortcutOptions) {
|
||||||
@@ -35,11 +36,25 @@ export function useAppKeyboard(options: ShortcutOptions) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key.name === "escape") {
|
||||||
|
options.onAction?.("escape")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Skip global shortcuts if input is focused (let input handle keys)
|
// Skip global shortcuts if input is focused (let input handle keys)
|
||||||
if (options.inputFocused) {
|
if (options.inputFocused) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.navigationEnabled === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "enter") {
|
||||||
|
options.onAction?.("enter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Tab navigation with left/right arrows OR [ and ]
|
// Tab navigation with left/right arrows OR [ and ]
|
||||||
if (key.name === "right" || key.name === "]") {
|
if (key.name === "right" || key.name === "]") {
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
options.onTabChange(getNextTab(options.activeTab))
|
||||||
@@ -89,8 +104,6 @@ export function useAppKeyboard(options: ShortcutOptions) {
|
|||||||
options.onAction("save")
|
options.onAction("save")
|
||||||
} else if (key.ctrl && key.name === "f") {
|
} else if (key.ctrl && key.name === "f") {
|
||||||
options.onAction("find")
|
options.onAction("find")
|
||||||
} else if (key.name === "escape") {
|
|
||||||
options.onAction("escape")
|
|
||||||
} else if (key.name === "?" || (key.shift && key.name === "/")) {
|
} else if (key.name === "?" || (key.shift && key.name === "/")) {
|
||||||
options.onAction("help")
|
options.onAction("help")
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/hooks/useCachedData.ts
Normal file
34
src/hooks/useCachedData.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
|
type CacheOptions<T> = {
|
||||||
|
fetcher: () => Promise<T>
|
||||||
|
intervalMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCachedData = <T,>(options: CacheOptions<T>) => {
|
||||||
|
const [data, setData] = createSignal<T | null>(null)
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const value = await options.fetcher()
|
||||||
|
setData(() => value)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load data")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
if (options.intervalMs) {
|
||||||
|
const interval = setInterval(refresh, options.intervalMs)
|
||||||
|
onCleanup(() => clearInterval(interval))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, loading, error, refresh }
|
||||||
|
}
|
||||||
109
src/stores/app.ts
Normal file
109
src/stores/app.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { DEFAULT_THEME, THEMES } from "../constants/themes"
|
||||||
|
import type { AppSettings, AppState, ThemeColors, ThemeName, UserPreferences } from "../types/settings"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "podtui_app_state"
|
||||||
|
|
||||||
|
const defaultSettings: AppSettings = {
|
||||||
|
theme: "system",
|
||||||
|
fontSize: 14,
|
||||||
|
playbackSpeed: 1,
|
||||||
|
downloadPath: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPreferences: UserPreferences = {
|
||||||
|
showExplicit: false,
|
||||||
|
autoDownload: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: AppState = {
|
||||||
|
settings: defaultSettings,
|
||||||
|
preferences: defaultPreferences,
|
||||||
|
customTheme: DEFAULT_THEME,
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadState = (): AppState => {
|
||||||
|
if (typeof localStorage === "undefined") return defaultState
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return defaultState
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AppState>
|
||||||
|
return {
|
||||||
|
settings: { ...defaultSettings, ...parsed.settings },
|
||||||
|
preferences: { ...defaultPreferences, ...parsed.preferences },
|
||||||
|
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return defaultState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveState = (state: AppState) => {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppStore() {
|
||||||
|
const [state, setState] = createSignal<AppState>(loadState())
|
||||||
|
|
||||||
|
const updateState = (next: AppState) => {
|
||||||
|
setState(next)
|
||||||
|
saveState(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSettings = (updates: Partial<AppSettings>) => {
|
||||||
|
const next = {
|
||||||
|
...state(),
|
||||||
|
settings: { ...state().settings, ...updates },
|
||||||
|
}
|
||||||
|
updateState(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
||||||
|
const next = {
|
||||||
|
...state(),
|
||||||
|
preferences: { ...state().preferences, ...updates },
|
||||||
|
}
|
||||||
|
updateState(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
||||||
|
const next = {
|
||||||
|
...state(),
|
||||||
|
customTheme: { ...state().customTheme, ...updates },
|
||||||
|
}
|
||||||
|
updateState(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTheme = (theme: ThemeName) => {
|
||||||
|
updateSettings({ theme })
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTheme = (): ThemeColors => {
|
||||||
|
const theme = state().settings.theme
|
||||||
|
if (theme === "custom") return state().customTheme
|
||||||
|
return THEMES[theme] ?? DEFAULT_THEME
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
updateSettings,
|
||||||
|
updatePreferences,
|
||||||
|
updateCustomTheme,
|
||||||
|
setTheme,
|
||||||
|
resolveTheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let appStoreInstance: ReturnType<typeof createAppStore> | null = null
|
||||||
|
|
||||||
|
export function useAppStore() {
|
||||||
|
if (!appStoreInstance) {
|
||||||
|
appStoreInstance = createAppStore()
|
||||||
|
}
|
||||||
|
return appStoreInstance
|
||||||
|
}
|
||||||
32
src/types/settings.ts
Normal file
32
src/types/settings.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export type ThemeName = "system" | "catppuccin" | "gruvbox" | "tokyo" | "nord" | "custom"
|
||||||
|
|
||||||
|
export type ThemeColors = {
|
||||||
|
background: string
|
||||||
|
surface: string
|
||||||
|
primary: string
|
||||||
|
secondary: string
|
||||||
|
accent: string
|
||||||
|
text: string
|
||||||
|
muted: string
|
||||||
|
warning: string
|
||||||
|
error: string
|
||||||
|
success: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppSettings = {
|
||||||
|
theme: ThemeName
|
||||||
|
fontSize: number
|
||||||
|
playbackSpeed: number
|
||||||
|
downloadPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserPreferences = {
|
||||||
|
showExplicit: boolean
|
||||||
|
autoDownload: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppState = {
|
||||||
|
settings: AppSettings
|
||||||
|
preferences: UserPreferences
|
||||||
|
customTheme: ThemeColors
|
||||||
|
}
|
||||||
57
src/utils/cache.ts
Normal file
57
src/utils/cache.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
type CacheEntry<T> = {
|
||||||
|
value: T
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = "podtui_cache"
|
||||||
|
const DEFAULT_TTL = 1000 * 60 * 60
|
||||||
|
|
||||||
|
const loadCache = (): Record<string, CacheEntry<unknown>> => {
|
||||||
|
if (typeof localStorage === "undefined") return {}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = loadCache()
|
||||||
|
|
||||||
|
export const cacheValue = <T,>(key: string, value: T) => {
|
||||||
|
cache[key] = { value, timestamp: Date.now() }
|
||||||
|
saveCache(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
|
||||||
|
const entry = cache[key] as CacheEntry<T> | undefined
|
||||||
|
if (!entry) return null
|
||||||
|
if (Date.now() - entry.timestamp > ttl) {
|
||||||
|
delete cache[key]
|
||||||
|
saveCache(cache)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invalidateCache = (prefix?: string) => {
|
||||||
|
if (!prefix) {
|
||||||
|
Object.keys(cache).forEach((key) => delete cache[key])
|
||||||
|
saveCache(cache)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(cache)
|
||||||
|
.filter((key) => key.startsWith(prefix))
|
||||||
|
.forEach((key) => delete cache[key])
|
||||||
|
saveCache(cache)
|
||||||
|
}
|
||||||
57
src/utils/data-fetcher.ts
Normal file
57
src/utils/data-fetcher.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { FeedVisibility } from "../types/feed"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import { cacheValue, getCachedValue } from "./cache"
|
||||||
|
import { fetchEpisodes } from "@/api/client"
|
||||||
|
|
||||||
|
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
|
||||||
|
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
|
||||||
|
const searchKey = (query: string) => `search:${query.toLowerCase()}`
|
||||||
|
|
||||||
|
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
|
||||||
|
const cached = getCachedValue<Feed>(feedKey(feedUrl))
|
||||||
|
if (cached) return cached
|
||||||
|
try {
|
||||||
|
const episodes = await fetchEpisodes(feedUrl)
|
||||||
|
const feed: Feed = {
|
||||||
|
id: feedUrl,
|
||||||
|
podcast: {
|
||||||
|
id: feedUrl,
|
||||||
|
title: feedUrl,
|
||||||
|
description: "",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes,
|
||||||
|
visibility: FeedVisibility.PUBLIC,
|
||||||
|
sourceId: "rss",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isPinned: false,
|
||||||
|
}
|
||||||
|
cacheValue(feedKey(feedUrl), feed)
|
||||||
|
return feed
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
|
||||||
|
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
|
||||||
|
if (cached) return cached
|
||||||
|
const episodes = await fetchEpisodes(feedUrl)
|
||||||
|
cacheValue(episodesKey(feedUrl), episodes)
|
||||||
|
return episodes
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchWithCache = async (
|
||||||
|
query: string,
|
||||||
|
fetcher: () => Promise<Podcast[]>
|
||||||
|
): Promise<Podcast[]> => {
|
||||||
|
const cached = getCachedValue<Podcast[]>(searchKey(query))
|
||||||
|
if (cached) return cached
|
||||||
|
const results = await fetcher()
|
||||||
|
cacheValue(searchKey(query), results)
|
||||||
|
return results
|
||||||
|
}
|
||||||
77
src/utils/persistence.ts
Normal file
77
src/utils/persistence.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { AppSettings, UserPreferences } from "../types/settings"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
settings: "podtui_settings",
|
||||||
|
preferences: "podtui_preferences",
|
||||||
|
feeds: "podtui_feeds",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const savePreference = (key: keyof UserPreferences, value: boolean) => {
|
||||||
|
const current = loadPreferences()
|
||||||
|
const next = { ...current, [key]: value }
|
||||||
|
savePreferences(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadPreference = (key: keyof UserPreferences) => {
|
||||||
|
return loadPreferences()[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveSettings = (settings: AppSettings) => {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadSettings = (): AppSettings | null => {
|
||||||
|
if (typeof localStorage === "undefined") return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEYS.settings)
|
||||||
|
return raw ? (JSON.parse(raw) as AppSettings) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const savePreferences = (preferences: UserPreferences) => {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.preferences, JSON.stringify(preferences))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadPreferences = (): UserPreferences => {
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return { showExplicit: false, autoDownload: false }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEYS.preferences)
|
||||||
|
return raw ? (JSON.parse(raw) as UserPreferences) : { showExplicit: false, autoDownload: false }
|
||||||
|
} catch {
|
||||||
|
return { showExplicit: false, autoDownload: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveFeeds = (feeds: Feed[]) => {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadFeeds = (): Feed[] => {
|
||||||
|
if (typeof localStorage === "undefined") return []
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEYS.feeds)
|
||||||
|
return raw ? (JSON.parse(raw) as Feed[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/utils/waveform.ts
Normal file
8
src/utils/waveform.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const createWaveform = (width: number): number[] => {
|
||||||
|
const data: number[] = []
|
||||||
|
for (let i = 0; i < width; i += 1) {
|
||||||
|
const value = 0.2 + Math.abs(Math.sin(i / 3)) * 0.8
|
||||||
|
data.push(Number(value.toFixed(2)))
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
52
tasks/podcast-tui-app/50-global-state-store.md
Normal file
52
tasks/podcast-tui-app/50-global-state-store.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 50. Create Global State Store (Signals)
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-50
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [66]
|
||||||
|
tags: [state-management, signals, solidjs, global-store]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Create a global state store using SolidJS Signals for reactive state management
|
||||||
|
- Implement a store that manages application-wide state (feeds, settings, user, etc.)
|
||||||
|
- Provide reactive subscriptions for state changes
|
||||||
|
- Ensure thread-safe state updates
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/store/index.ts` - Global state store with Signals
|
||||||
|
- `/src/store/types.ts` - State type definitions
|
||||||
|
- `/src/store/hooks.ts` - Custom hooks for state access
|
||||||
|
- Updated `src/index.tsx` to initialize the store
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Define state interface with all application state properties (feeds, settings, user, etc.)
|
||||||
|
- Create Signal-based store using `createSignal` from SolidJS
|
||||||
|
- Implement computed signals for derived state (filtered feeds, search results, etc.)
|
||||||
|
- Create state update functions that trigger reactivity
|
||||||
|
- Add subscription mechanism for reactive UI updates
|
||||||
|
- Export store and hooks for use across components
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test that signals update correctly when state changes
|
||||||
|
- Unit: Test computed signals produce correct derived values
|
||||||
|
- Integration: Verify store updates trigger UI re-renders
|
||||||
|
- Integration: Test multiple components can subscribe to same state
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Store can be initialized with default state
|
||||||
|
- State changes trigger reactive updates in components
|
||||||
|
- Computed signals work correctly for derived state
|
||||||
|
- Multiple components can access and subscribe to store
|
||||||
|
- State updates are thread-safe
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Run application and verify state changes are reactive
|
||||||
|
- Check console for any errors during state updates
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use `createSignal`, `createComputed`, `createEffect` from SolidJS
|
||||||
|
- Store should follow single source of truth pattern
|
||||||
|
- Consider using `batch` for multiple state updates
|
||||||
|
- State should be serializable for persistence
|
||||||
56
tasks/podcast-tui-app/59-theme-system.md
Normal file
56
tasks/podcast-tui-app/59-theme-system.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 59. Create Theme System Architecture
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-59
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [09]
|
||||||
|
tags: [theming, architecture, solidjs, design-system]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Design and implement a flexible theme system architecture
|
||||||
|
- Support multiple color themes (Catppuccin, Gruvbox, Tokyo, Nord, custom)
|
||||||
|
- Provide theme switching capabilities
|
||||||
|
- Ensure theme persistence across sessions
|
||||||
|
- Define theme properties (colors, fonts, spacing, borders)
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/themes/types.ts` - Theme type definitions
|
||||||
|
- `/src/themes/theme.ts` - Theme system core implementation
|
||||||
|
- `/src/themes/themes/` - Directory with theme files
|
||||||
|
- `/src/themes/default.ts` - Default system theme
|
||||||
|
- `/src/themes/hooks.ts` - Theme hooks (useTheme, useThemeColor)
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Define `Theme` interface with properties: name, colors, fonts, spacing, borders
|
||||||
|
- Create theme color palettes (background, foreground, primary, secondary, accent, etc.)
|
||||||
|
- Implement `ThemeManager` class to handle theme loading, switching, and persistence
|
||||||
|
- Create `useTheme` hook for React/Solid components to access current theme
|
||||||
|
- Implement `useThemeColor` hook for accessing specific theme colors
|
||||||
|
- Add theme persistence using localStorage or file-based storage
|
||||||
|
- Export theme utilities for programmatic theme access
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme loading from JSON file
|
||||||
|
- Unit: Test theme switching updates current theme
|
||||||
|
- Unit: Test theme persistence saves/loads correctly
|
||||||
|
- Integration: Verify components update when theme changes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Theme system can load multiple theme definitions
|
||||||
|
- Theme switching works instantly across all components
|
||||||
|
- Theme preferences persist across application restarts
|
||||||
|
- Theme colors are accessible via hooks
|
||||||
|
- Theme manager handles errors gracefully
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test theme switching manually in application
|
||||||
|
- Verify localStorage/file storage works
|
||||||
|
- Check all theme colors render correctly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Theme files should be JSON or TypeScript modules
|
||||||
|
- Use CSS variables or terminal color codes
|
||||||
|
- Consider dark/light mode compatibility
|
||||||
|
- Themes should be easily extensible
|
||||||
52
tasks/podcast-tui-app/60-default-theme.md
Normal file
52
tasks/podcast-tui-app/60-default-theme.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 60. Implement Default Theme (System Terminal)
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-60
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [59]
|
||||||
|
tags: [theming, default, solidjs, terminal]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement the default system terminal theme
|
||||||
|
- Define colors matching common terminal environments
|
||||||
|
- Ensure good readability and contrast
|
||||||
|
- Support both light and dark terminal modes
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/themes/themes/default.ts` - Default theme definition
|
||||||
|
- `/src/themes/themes/default-light.ts` - Light mode theme
|
||||||
|
- `/src/themes/themes/default-dark.ts` - Dark mode theme
|
||||||
|
- Updated `/src/themes/theme.ts` to load default theme
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Define default color palette for system terminals
|
||||||
|
- Create light mode theme with standard terminal colors
|
||||||
|
- Create dark mode theme with standard terminal colors
|
||||||
|
- Ensure proper contrast ratios for readability
|
||||||
|
- Test theme in both light and dark terminal environments
|
||||||
|
- Export default theme as fallback
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Verify default theme colors are defined
|
||||||
|
- Unit: Test theme renders correctly in light mode
|
||||||
|
- Unit: Test theme renders correctly in dark mode
|
||||||
|
- Visual: Verify text contrast meets accessibility standards
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Default theme works in light terminal mode
|
||||||
|
- Default theme works in dark terminal mode
|
||||||
|
- Colors have good readability and contrast
|
||||||
|
- Theme is used as fallback when no theme selected
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test in light terminal (e.g., iTerm2, Terminal.app)
|
||||||
|
- Test in dark terminal (e.g., Kitty, Alacritty)
|
||||||
|
- Check color contrast visually
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use standard terminal color codes (ANSI escape codes)
|
||||||
|
- Consider common terminal themes (Solarized, Dracula, etc.)
|
||||||
|
- Test on multiple terminal emulators
|
||||||
|
- Document terminal requirements
|
||||||
52
tasks/podcast-tui-app/61-catppuccin-theme.md
Normal file
52
tasks/podcast-tui-app/61-catppuccin-theme.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 61. Add Catppuccin Theme
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-61
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [59]
|
||||||
|
tags: [theming, catppuccin, solidjs, popular]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement Catppuccin Mocha theme for the podcast TUI
|
||||||
|
- Provide beautiful, modern color scheme
|
||||||
|
- Ensure high contrast and readability
|
||||||
|
- Support both dark and light variants
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/themes/themes/catppuccin.ts` - Catppuccin theme definition
|
||||||
|
- `/src/themes/themes/catppuccin-mocha.ts` - Dark mode Catppuccin
|
||||||
|
- `/src/themes/themes/catppuccin-latte.ts` - Light mode Catppuccin
|
||||||
|
- Updated `/src/themes/themes/index.ts` to export Catppuccin themes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Research and implement Catppuccin Mocha color palette
|
||||||
|
- Define all color tokens (background, foreground, surface, primary, secondary, etc.)
|
||||||
|
- Create Catppuccin Mocha (dark) theme
|
||||||
|
- Create Catppuccin Latte (light) theme
|
||||||
|
- Ensure proper color contrast for accessibility
|
||||||
|
- Add Catppuccin to theme registry
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Verify Catppuccin theme colors are defined
|
||||||
|
- Unit: Test Catppuccin Mocha renders correctly
|
||||||
|
- Unit: Test Catppuccin Latte renders correctly
|
||||||
|
- Visual: Verify Catppuccin colors are visually appealing
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Catppuccin Mocha theme works in dark terminals
|
||||||
|
- Catppuccin Latte theme works in light terminals
|
||||||
|
- Colors match official Catppuccin design
|
||||||
|
- Theme is selectable from theme list
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test Catppuccin theme manually in application
|
||||||
|
- Compare with official Catppuccin color palette
|
||||||
|
- Check color harmony and contrast
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Catppuccin Mocha is the recommended dark theme
|
||||||
|
- Include all standard Catppuccin colors (latte, frappe, macchiato, mocha)
|
||||||
|
- Use official color values from Catppuccin repository
|
||||||
|
- Consider adding custom color variations
|
||||||
52
tasks/podcast-tui-app/62-gruvbox-theme.md
Normal file
52
tasks/podcast-tui-app/62-gruvbox-theme.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 62. Add Gruvbox Theme
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-62
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [59]
|
||||||
|
tags: [theming, gruvbox, solidjs, retro]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement Gruvbox Dark theme for the podcast TUI
|
||||||
|
- Provide warm, nostalgic color scheme
|
||||||
|
- Ensure good contrast and readability
|
||||||
|
- Support both dark and light variants
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/themes/themes/gruvbox.ts` - Gruvbox theme definition
|
||||||
|
- `/src/themes/themes/gruvbox-dark.ts` - Dark mode Gruvbox
|
||||||
|
- `/src/themes/themes/gruvbox-light.ts` - Light mode Gruvbox
|
||||||
|
- Updated `/src/themes/themes/index.ts` to export Gruvbox themes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Research and implement Gruvbox Dark theme color palette
|
||||||
|
- Define all color tokens (background, foreground, primary, secondary, etc.)
|
||||||
|
- Create Gruvbox Dark theme
|
||||||
|
- Create Gruvbox Light theme
|
||||||
|
- Ensure proper color contrast for accessibility
|
||||||
|
- Add Gruvbox to theme registry
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Verify Gruvbox theme colors are defined
|
||||||
|
- Unit: Test Gruvbox Dark renders correctly
|
||||||
|
- Unit: Test Gruvbox Light renders correctly
|
||||||
|
- Visual: Verify Gruvbox colors are visually appealing
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Gruvbox Dark theme works in dark terminals
|
||||||
|
- Gruvbox Light theme works in light terminals
|
||||||
|
- Colors match official Gruvbox design
|
||||||
|
- Theme is selectable from theme list
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test Gruvbox theme manually in application
|
||||||
|
- Compare with official Gruvbox color palette
|
||||||
|
- Check color harmony and contrast
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Gruvbox Dark is the recommended variant
|
||||||
|
- Include all standard Gruvbox colors (hard, soft, light)
|
||||||
|
- Use official color values from Gruvbox repository
|
||||||
|
- Gruvbox is popular among developers for its warmth
|
||||||
52
tasks/podcast-tui-app/63-tokyo-theme.md
Normal file
52
tasks/podcast-tui-app/63-tokyo-theme.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 63. Add Tokyo Night Theme
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-63
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [59]
|
||||||
|
tags: [theming, tokyo-night, solidjs, modern]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement Tokyo Night theme for the podcast TUI
|
||||||
|
- Provide modern, vibrant color scheme
|
||||||
|
- Ensure good contrast and readability
|
||||||
|
- Support both dark and light variants
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/themes/themes/tokyo-night.ts` - Tokyo Night theme definition
|
||||||
|
- `/src/themes/themes/tokyo-night-day.ts` - Light mode Tokyo Night
|
||||||
|
- `/src/themes/themes/tokyo-night-night.ts` - Dark mode Tokyo Night
|
||||||
|
- Updated `/src/themes/themes/index.ts` to export Tokyo Night themes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Research and implement Tokyo Night theme color palette
|
||||||
|
- Define all color tokens (background, foreground, primary, secondary, etc.)
|
||||||
|
- Create Tokyo Night Night (dark) theme
|
||||||
|
- Create Tokyo Night Day (light) theme
|
||||||
|
- Ensure proper color contrast for accessibility
|
||||||
|
- Add Tokyo Night to theme registry
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Verify Tokyo Night theme colors are defined
|
||||||
|
- Unit: Test Tokyo Night Night renders correctly
|
||||||
|
- Unit: Test Tokyo Night Day renders correctly
|
||||||
|
- Visual: Verify Tokyo Night colors are visually appealing
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Tokyo Night Night theme works in dark terminals
|
||||||
|
- Tokyo Night Day theme works in light terminals
|
||||||
|
- Colors match official Tokyo Night design
|
||||||
|
- Theme is selectable from theme list
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test Tokyo Night theme manually in application
|
||||||
|
- Compare with official Tokyo Night color palette
|
||||||
|
- Check color harmony and contrast
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Tokyo Night Night is the recommended variant
|
||||||
|
- Include all standard Tokyo Night colors
|
||||||
|
- Use official color values from Tokyo Night repository
|
||||||
|
- Tokyo Night is popular for its modern, clean look
|
||||||
52
tasks/podcast-tui-app/64-nord-theme.md
Normal file
52
tasks/podcast-tui-app/64-nord-theme.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 64. Add Nord Theme
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-64
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [59]
|
||||||
|
tags: [theming, nord, solidjs, minimal]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement Nord theme for the podcast TUI
|
||||||
|
- Provide clean, minimal color scheme
|
||||||
|
- Ensure good contrast and readability
|
||||||
|
- Support both dark and light variants
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/themes/themes/nord.ts` - Nord theme definition
|
||||||
|
- `/src/themes/themes/nord-dark.ts` - Dark mode Nord
|
||||||
|
- `/src/themes/themes/nord-light.ts` - Light mode Nord
|
||||||
|
- Updated `/src/themes/themes/index.ts` to export Nord themes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Research and implement Nord theme color palette
|
||||||
|
- Define all color tokens (background, foreground, primary, secondary, etc.)
|
||||||
|
- Create Nord Dark theme
|
||||||
|
- Create Nord Light theme
|
||||||
|
- Ensure proper color contrast for accessibility
|
||||||
|
- Add Nord to theme registry
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Verify Nord theme colors are defined
|
||||||
|
- Unit: Test Nord Dark renders correctly
|
||||||
|
- Unit: Test Nord Light renders correctly
|
||||||
|
- Visual: Verify Nord colors are visually appealing
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Nord Dark theme works in dark terminals
|
||||||
|
- Nord Light theme works in light terminals
|
||||||
|
- Colors match official Nord design
|
||||||
|
- Theme is selectable from theme list
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test Nord theme manually in application
|
||||||
|
- Compare with official Nord color palette
|
||||||
|
- Check color harmony and contrast
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Nord Dark is the recommended variant
|
||||||
|
- Include all standard Nord colors
|
||||||
|
- Use official color values from Nord repository
|
||||||
|
- Nord is popular for its minimalist, clean aesthetic
|
||||||
56
tasks/podcast-tui-app/65-custom-theme.md
Normal file
56
tasks/podcast-tui-app/65-custom-theme.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 65. Implement Custom Theme Editor
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-65
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P2
|
||||||
|
depends_on: [59]
|
||||||
|
tags: [theming, editor, custom, solidjs]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Build a UI for creating and editing custom themes
|
||||||
|
- Allow users to modify color palettes
|
||||||
|
- Provide live preview of theme changes
|
||||||
|
- Save custom themes to storage
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/components/ThemeEditor.tsx` - Theme editor component
|
||||||
|
- `/src/components/ThemePreview.tsx` - Live theme preview component
|
||||||
|
- `/src/components/ColorPicker.tsx` - Custom color picker component
|
||||||
|
- `/src/store/theme-editor.ts` - Theme editor state management
|
||||||
|
- Updated settings screen to include theme editor
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Create ThemeEditor component with color palette editor
|
||||||
|
- Implement ColorPicker component for selecting theme colors
|
||||||
|
- Create ThemePreview component showing live theme changes
|
||||||
|
- Build form controls for editing all theme properties
|
||||||
|
- Add save functionality for custom themes
|
||||||
|
- Implement delete functionality for custom themes
|
||||||
|
- Add validation for theme color values
|
||||||
|
- Update settings screen to show theme editor
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme editor saves custom themes correctly
|
||||||
|
- Unit: Test color picker updates theme colors
|
||||||
|
- Unit: Test theme preview updates in real-time
|
||||||
|
- Integration: Test custom theme can be loaded
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Theme editor allows editing all theme colors
|
||||||
|
- Custom theme can be saved and loaded
|
||||||
|
- Live preview shows theme changes in real-time
|
||||||
|
- Custom themes persist across sessions
|
||||||
|
- Invalid color values are rejected
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test theme editor manually in application
|
||||||
|
- Verify custom themes save/load correctly
|
||||||
|
- Check live preview works smoothly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing theme type definitions
|
||||||
|
- Provide preset color palettes for quick selection
|
||||||
|
- Consider adding theme templates
|
||||||
|
- Ensure editor works on small terminal sizes
|
||||||
56
tasks/podcast-tui-app/66-theme-selector.md
Normal file
56
tasks/podcast-tui-app/66-theme-selector.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 66. Add Theme Selector in Settings
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-66
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P1
|
||||||
|
depends_on: [59, 60, 61, 62, 63, 64, 65]
|
||||||
|
tags: [theming, settings, selector, solidjs]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Add theme selection UI in settings screen
|
||||||
|
- Display all available themes (default, Catppuccin, Gruvbox, Tokyo, Nord, custom)
|
||||||
|
- Allow users to select and switch themes
|
||||||
|
- Show currently selected theme
|
||||||
|
- Persist theme preference
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/components/ThemeSelector.tsx` - Theme selector component
|
||||||
|
- Updated `/src/components/SettingsScreen.tsx` to include theme selector
|
||||||
|
- Updated `/src/store/theme.ts` to handle theme preference persistence
|
||||||
|
- Theme selector in settings navigation
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Create ThemeSelector component with theme list
|
||||||
|
- Implement theme selection logic
|
||||||
|
- Display current theme with visual indicator
|
||||||
|
- Add theme descriptions or icons
|
||||||
|
- Integrate theme selector into Settings screen
|
||||||
|
- Save theme preference to storage
|
||||||
|
- Handle theme switching with instant updates
|
||||||
|
- Add keyboard navigation for theme list
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme selector displays all themes
|
||||||
|
- Unit: Test theme selection updates current theme
|
||||||
|
- Unit: Test theme preference saves to storage
|
||||||
|
- Integration: Test theme switching works across all components
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Theme selector shows all available themes
|
||||||
|
- Users can select and switch themes
|
||||||
|
- Current theme is clearly indicated
|
||||||
|
- Theme preference persists across sessions
|
||||||
|
- Theme changes apply immediately to all components
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test theme selector manually in settings
|
||||||
|
- Verify theme preference saves/loads correctly
|
||||||
|
- Check theme applies to all UI components
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing theme manager for theme switching
|
||||||
|
- Consider adding theme search/filter
|
||||||
|
- Show theme preview in selector if possible
|
||||||
|
- Ensure accessibility for keyboard navigation
|
||||||
57
tasks/podcast-tui-app/67-browser-redirect.md
Normal file
57
tasks/podcast-tui-app/67-browser-redirect.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 67. Implement Browser Redirect Flow for OAuth
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-67
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P2
|
||||||
|
depends_on: [04]
|
||||||
|
tags: [oauth, authentication, browser, solidjs]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement browser redirect flow for OAuth authentication
|
||||||
|
- Handle OAuth callback in terminal application
|
||||||
|
- Exchange authorization code for access token
|
||||||
|
- Store authentication tokens securely
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/auth/oauth-redirect.ts` - OAuth redirect handler
|
||||||
|
- `/src/auth/oauth-callback.ts` - OAuth callback handler
|
||||||
|
- `/src/auth/token-handler.ts` - Token exchange and storage
|
||||||
|
- Updated `/src/auth/login-screen.tsx` with OAuth option
|
||||||
|
- Updated `/src/auth/authentication-state.ts` for OAuth state
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Implement OAuth authorization URL generation with client ID/secret
|
||||||
|
- Create OAuth redirect handler to capture callback
|
||||||
|
- Handle authorization code exchange with token endpoint
|
||||||
|
- Store access token and refresh token securely
|
||||||
|
- Implement error handling for OAuth failures
|
||||||
|
- Add OAuth state parameter for security
|
||||||
|
- Update authentication state to track OAuth login status
|
||||||
|
- Add OAuth logout functionality
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test OAuth authorization URL generation
|
||||||
|
- Unit: Test token exchange with valid/invalid code
|
||||||
|
- Unit: Test token storage and retrieval
|
||||||
|
- Integration: Test OAuth flow from start to finish
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- OAuth authorization URL is generated correctly
|
||||||
|
- OAuth callback is handled without errors
|
||||||
|
- Access token is stored securely
|
||||||
|
- OAuth flow works with valid credentials
|
||||||
|
- OAuth errors are handled gracefully
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test OAuth flow manually with test credentials
|
||||||
|
- Verify token storage in localStorage
|
||||||
|
- Check error handling for invalid tokens
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use standard OAuth 2.0 flow (authorization code grant)
|
||||||
|
- Document OAuth requirements (client ID, redirect URI)
|
||||||
|
- Consider using PKCE for enhanced security
|
||||||
|
- Test with real OAuth provider if possible
|
||||||
|
- Document limitations and requirements
|
||||||
58
tasks/podcast-tui-app/68-qr-code-display.md
Normal file
58
tasks/podcast-tui-app/68-qr-code-display.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 68. Build QR Code Display for Mobile Verification
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podcast-tui-app-68
|
||||||
|
feature: podcast-tui-app
|
||||||
|
priority: P2
|
||||||
|
depends_on: [04]
|
||||||
|
tags: [authentication, qr-code, mobile, verification, solidjs]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Display QR code for mobile device verification
|
||||||
|
- Generate QR code from verification URL
|
||||||
|
- Handle manual code entry as fallback
|
||||||
|
- Show verification status and expiration
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `/src/components/QrCodeDisplay.tsx` - QR code display component
|
||||||
|
- `/src/utils/qr-code-generator.ts` - QR code generation utility
|
||||||
|
- `/src/auth/verification-handler.ts` - Verification flow handler
|
||||||
|
- Updated `/src/auth/login-screen.tsx` with QR code option
|
||||||
|
- Updated `/src/auth/code-validation.tsx` for manual entry
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Integrate QR code generation library (e.g., `qrcode` package)
|
||||||
|
- Create QRCodeDisplay component with generated QR image
|
||||||
|
- Implement verification URL generation
|
||||||
|
- Add manual code entry fallback UI
|
||||||
|
- Show verification expiration timer
|
||||||
|
- Display verification success/failure status
|
||||||
|
- Handle QR code scan timeout
|
||||||
|
- Update authentication state on successful verification
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test QR code generation
|
||||||
|
- Unit: Test verification URL generation
|
||||||
|
- Unit: Test verification code validation
|
||||||
|
- Integration: Test complete verification flow
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- QR code is generated correctly from verification URL
|
||||||
|
- QR code is displayed in terminal
|
||||||
|
- Manual code entry works as fallback
|
||||||
|
- Verification status is shown clearly
|
||||||
|
- Verification expires after timeout
|
||||||
|
- Successful verification updates auth state
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run build` to verify TypeScript compilation
|
||||||
|
- Test QR code display manually
|
||||||
|
- Test manual code entry fallback
|
||||||
|
- Verify verification flow works end-to-end
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use `qrcode` or similar library for QR generation
|
||||||
|
- Display QR code in ASCII or image format
|
||||||
|
- Consider using external scanner app
|
||||||
|
- Add verification expiration (e.g., 5 minutes)
|
||||||
|
- Document mobile app requirements for scanning
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 01. Analyze Current Navigation and Layer System
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-01
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [analysis, debugging, navigation]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Analyze current navigation implementation and layer system
|
||||||
|
- Identify issues with the existing layerDepth signal
|
||||||
|
- Document current navigation behavior and identify gaps
|
||||||
|
- Understand how layers should work per user requirements
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Analysis document with current navigation state
|
||||||
|
- List of identified issues and gaps
|
||||||
|
- Recommendations for navigation improvements
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read src/App.tsx to understand current layerDepth implementation
|
||||||
|
- Read src/components/Layout.tsx to understand layout structure
|
||||||
|
- Read src/hooks/useAppKeyboard.ts to understand keyboard handling
|
||||||
|
- Read src/components/TabNavigation.tsx and src/components/Navigation.tsx
|
||||||
|
- Review how layerDepth is used across components
|
||||||
|
- Identify issues with current navigation UX
|
||||||
|
- Document requirements: clear layer separation, active layer bg colors, left/right navigation, enter/escape controls
|
||||||
|
- Create analysis summary
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: None (analysis task)
|
||||||
|
- Integration: None (analysis task)
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Analysis document is created and saved
|
||||||
|
- All current navigation patterns are documented
|
||||||
|
- All identified issues and gaps are listed
|
||||||
|
- Clear recommendations are provided for navigation improvements
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Review analysis document for completeness
|
||||||
|
- Verify all relevant files were analyzed
|
||||||
|
- Check that requirements are clearly documented
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Focus on understanding the gap between current implementation and user requirements
|
||||||
|
- Pay special attention to how layerDepth signal is managed
|
||||||
|
- Note any issues with keyboard event handling
|
||||||
|
- Consider how to make navigation more intuitive
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 02. Fix Discover Tab Crash
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-02
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P1
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-01]
|
||||||
|
tags: [bug-fix, discover, crash]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Identify and fix crash when Discover tab is selected
|
||||||
|
- Ensure DiscoverPage component loads without errors
|
||||||
|
- Test all functionality in Discover tab
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Fixed DiscoverPage.tsx component
|
||||||
|
- Debugged crash identified and resolved
|
||||||
|
- Test results showing no crashes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read src/components/DiscoverPage.tsx thoroughly
|
||||||
|
- Check for null/undefined references in DiscoverPage
|
||||||
|
- Verify useDiscoverStore() is properly initialized
|
||||||
|
- Check DISCOVER_CATEGORIES constant
|
||||||
|
- Verify TrendingShows component works correctly
|
||||||
|
- Check CategoryFilter component for issues
|
||||||
|
- Add null checks and error boundaries if needed
|
||||||
|
- Test tab selection in App.tsx
|
||||||
|
- Verify no console errors
|
||||||
|
- Test all keyboard shortcuts in Discover tab
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test DiscoverPage component with mocked store
|
||||||
|
- Integration: Test Discover tab selection and navigation
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Discover tab can be selected without crashes
|
||||||
|
- No console errors when Discover tab is active
|
||||||
|
- All DiscoverPage functionality works (keyboard shortcuts, navigation)
|
||||||
|
- TrendingShows and CategoryFilter components render correctly
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and select Discover tab
|
||||||
|
- Check console for errors
|
||||||
|
- Test all keyboard interactions (j/k, tab, enter, escape, r)
|
||||||
|
- Verify content renders correctly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Common crash causes: null store, undefined categories, missing component imports
|
||||||
|
- Check for unhandled promises or async operations
|
||||||
|
- Verify all props are properly passed from App.tsx
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 03. Fix My Feeds Tab Crash
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-03
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P1
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-01]
|
||||||
|
tags: [bug-fix, feeds, crash]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Identify and fix crash when My Feeds tab is selected
|
||||||
|
- Ensure FeedList component loads without errors
|
||||||
|
- Test all functionality in My Feeds tab
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Fixed FeedList.tsx component
|
||||||
|
- Debugged crash identified and resolved
|
||||||
|
- Test results showing no crashes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read src/components/FeedList.tsx thoroughly
|
||||||
|
- Check for null/undefined references in FeedList
|
||||||
|
- Verify useFeedStore() is properly initialized
|
||||||
|
- Check FeedItem component for issues
|
||||||
|
- Verify filteredFeeds() returns valid array
|
||||||
|
- Add null checks and error boundaries if needed
|
||||||
|
- Test tab selection in App.tsx
|
||||||
|
- Verify no console errors
|
||||||
|
- Test all keyboard shortcuts in FeedList
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test FeedList component with mocked store
|
||||||
|
- Integration: Test Feeds tab selection and navigation
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- My Feeds tab can be selected without crashes
|
||||||
|
- No console errors when My Feeds tab is active
|
||||||
|
- All FeedList functionality works (keyboard shortcuts, navigation)
|
||||||
|
- FeedItem components render correctly
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and select My Feeds tab
|
||||||
|
- Check console for errors
|
||||||
|
- Test all keyboard interactions (j/k, enter, f, s, esc)
|
||||||
|
- Verify feed list renders correctly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Common crash causes: null store, undefined feeds, missing component imports
|
||||||
|
- Check for unhandled promises or async operations
|
||||||
|
- Verify all props are properly passed from App.tsx
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 04. Fix Settings/Sources Sub-tab Crash
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-04
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P1
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-01]
|
||||||
|
tags: [bug-fix, settings, crash]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Identify and fix crash when Settings/Sources sub-tab is selected
|
||||||
|
- Ensure SourceManager component loads without errors
|
||||||
|
- Test all functionality in Settings/Sources sub-tab
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Fixed SourceManager.tsx component
|
||||||
|
- Debugged crash identified and resolved
|
||||||
|
- Test results showing no crashes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read src/components/SourceManager.tsx thoroughly
|
||||||
|
- Check for null/undefined references in SourceManager
|
||||||
|
- Verify useFeedStore() is properly initialized
|
||||||
|
- Check all focus areas (list, add, url, country, explicit, language)
|
||||||
|
- Verify input component is properly imported and used
|
||||||
|
- Add null checks and error boundaries if needed
|
||||||
|
- Test Settings tab selection in App.tsx
|
||||||
|
- Test Sources sub-tab selection in SettingsScreen.tsx
|
||||||
|
- Verify no console errors
|
||||||
|
- Test all keyboard shortcuts and interactions
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test SourceManager component with mocked store
|
||||||
|
- Integration: Test Settings tab → Sources sub-tab navigation
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Settings tab can be selected without crashes
|
||||||
|
- Sources sub-tab can be selected without crashes
|
||||||
|
- No console errors when Settings/Sources sub-tab is active
|
||||||
|
- All SourceManager functionality works (keyboard shortcuts, navigation)
|
||||||
|
- All form inputs and buttons work correctly
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and select Settings tab
|
||||||
|
- Select Sources sub-tab and verify it loads
|
||||||
|
- Check console for errors
|
||||||
|
- Test all keyboard interactions (tab, esc, enter, space, a, d)
|
||||||
|
- Verify form inputs work correctly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Common crash causes: null store, undefined sources, missing component imports
|
||||||
|
- Check for unhandled promises or async operations
|
||||||
|
- Verify all props are properly passed from SettingsScreen.tsx
|
||||||
|
- Ensure useKeyboard hook doesn't conflict with parent keyboard handlers
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 05. Design Layered Navigation UI System
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-05
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-02, podtui-navigation-theming-improvements-03, podtui-navigation-theming-improvements-04]
|
||||||
|
tags: [design, navigation, ui]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Design the layered navigation UI based on user requirements
|
||||||
|
- Create visual design for layer separation and active states
|
||||||
|
- Define how layers should be displayed and navigated
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Navigation design document
|
||||||
|
- Layer visualization mockups
|
||||||
|
- Clear specifications for layer colors and borders
|
||||||
|
- Implementation plan for layered navigation
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Review user requirements for navigation (clear layer separation, bg colors, left/right navigation, enter/escape controls)
|
||||||
|
- Analyze current layerDepth signal implementation
|
||||||
|
- Design layer separation mechanism (borders, backgrounds, spacing)
|
||||||
|
- Define active layer visual state (bg color, borders, indicators)
|
||||||
|
- Design navigation controls (left/right arrows, enter arrow down, escape arrow up)
|
||||||
|
- Create layer visualization showing how multiple layers should work
|
||||||
|
- Document layer structure and hierarchy
|
||||||
|
- Create implementation plan for Navigation component
|
||||||
|
- Define theme colors for layer backgrounds
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: None (design task)
|
||||||
|
- Integration: None (design task)
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Navigation design document is created
|
||||||
|
- Layer separation mechanism is clearly specified
|
||||||
|
- Active layer visual state is defined
|
||||||
|
- Navigation controls are documented
|
||||||
|
- Implementation plan is provided
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Review design document for clarity
|
||||||
|
- Verify it addresses all user requirements
|
||||||
|
- Check that design is feasible to implement
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Layers should be clearly delineated with visual separation
|
||||||
|
- Active layer should have distinct background color
|
||||||
|
- Navigation should be intuitive with clear visual feedback
|
||||||
|
- Consider terminal width limitations
|
||||||
|
- Design should work with existing theme system
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 06. Implement Left/Right Layer Navigation Controls
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-06
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-05]
|
||||||
|
tags: [implementation, navigation, keyboard]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement left/right arrow key navigation between layers
|
||||||
|
- Add keyboard handlers for <left> and <right> keys
|
||||||
|
- Update navigation state to track current layer index
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Updated Navigation component with left/right navigation
|
||||||
|
- Keyboard handler implementation in App.tsx
|
||||||
|
- Updated layer management logic
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read references/keyboard/REFERENCE.md for keyboard handling patterns
|
||||||
|
- Design layer index management (currentLayer, maxLayers)
|
||||||
|
- Update Navigation component to show layer navigation hints
|
||||||
|
- Add <left> and <right> key handlers in App.tsx useAppKeyboard hook
|
||||||
|
- Update layerDepth signal to reflect current layer index
|
||||||
|
- Add visual indicators for current layer position
|
||||||
|
- Update layer rendering to show active layer with left/right arrows
|
||||||
|
- Test navigation between layers
|
||||||
|
- Ensure keyboard shortcuts don't conflict with page-specific shortcuts
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test keyboard handler with mocked key events
|
||||||
|
- Integration: Test left/right navigation between layers
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- <left> key navigates to previous layer
|
||||||
|
- <right> key navigates to next layer
|
||||||
|
- Current layer is visually indicated
|
||||||
|
- Navigation hints are shown in Navigation component
|
||||||
|
- No keyboard conflicts with page-specific shortcuts
|
||||||
|
- Navigation works correctly at layer boundaries
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and test left/right navigation
|
||||||
|
- Verify current layer is highlighted
|
||||||
|
- Check that navigation hints are visible
|
||||||
|
- Test at layer boundaries (first/last layer)
|
||||||
|
- Verify no conflicts with page shortcuts
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/keyboard/REFERENCE.md for proper keyboard handling patterns
|
||||||
|
- Consider accessibility and screen reader support
|
||||||
|
- Ensure consistent behavior across all pages
|
||||||
|
- Test with different terminal sizes
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 07. Implement Enter/Escape Layer Navigation Controls
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-07
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-05]
|
||||||
|
tags: [implementation, navigation, keyboard]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement <enter> key to go down into a layer
|
||||||
|
- Implement <escape> key to go up one layer
|
||||||
|
- Update layer navigation to support entering/exiting layers
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Updated layer navigation logic for enter/escape
|
||||||
|
- Updated Navigation component to show enter/escape hints
|
||||||
|
- Updated App.tsx keyboard handlers
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Update Navigation component to show enter/escape navigation hints
|
||||||
|
- Add <enter> key handler in App.tsx useAppKeyboard hook
|
||||||
|
- Add <escape> key handler in App.tsx useAppKeyboard hook
|
||||||
|
- Update layerDepth signal to track current layer (0 = top level)
|
||||||
|
- Implement logic for entering a layer (increase layerDepth)
|
||||||
|
- Implement logic for exiting a layer (decrease layerDepth)
|
||||||
|
- Add visual feedback when entering/exiting layers
|
||||||
|
- Update all page components to handle layerDepth prop
|
||||||
|
- Test enter to go down, escape to go up
|
||||||
|
- Ensure proper layer nesting behavior
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test keyboard handler with mocked key events
|
||||||
|
- Integration: Test enter/escape navigation between layers
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- <enter> key goes down into a layer
|
||||||
|
- <escape> key goes up one layer
|
||||||
|
- Navigation hints show enter/escape directions
|
||||||
|
- Layer depth is properly tracked and managed
|
||||||
|
- Visual feedback shows current layer depth
|
||||||
|
- No keyboard conflicts with page-specific shortcuts
|
||||||
|
- Proper layer nesting behavior
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and test enter/escape navigation
|
||||||
|
- Verify layer depth is visually indicated
|
||||||
|
- Check that navigation hints are visible
|
||||||
|
- Test proper layer nesting behavior
|
||||||
|
- Verify no conflicts with page shortcuts
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/keyboard/REFERENCE.md for proper keyboard handling patterns
|
||||||
|
- Consider terminal width limitations for layer hints
|
||||||
|
- Ensure consistent behavior across all pages
|
||||||
|
- Test with different layer depths
|
||||||
|
- Verify escape works at all layer depths
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 08. Design Active Layer Background Color System
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-08
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P3
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-05]
|
||||||
|
tags: [design, theming, navigation]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Design active layer background colors
|
||||||
|
- Define color palette for layer backgrounds
|
||||||
|
- Create theme-aware layer styling
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Layer color design document
|
||||||
|
- Theme tokens for layer backgrounds
|
||||||
|
- Implementation plan for layer styling
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Review existing theme system in src/constants/themes.ts
|
||||||
|
- Design layer background colors (active layer, inactive layers)
|
||||||
|
- Define color palette that works with existing themes
|
||||||
|
- Create theme tokens for layer backgrounds (e.g., layer-active-bg, layer-inactive-bg)
|
||||||
|
- Design border colors for layer separation
|
||||||
|
- Design indicator colors for current layer position
|
||||||
|
- Create implementation plan for applying layer colors
|
||||||
|
- Ensure colors work with system/light/dark modes
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: None (design task)
|
||||||
|
- Integration: None (design task)
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Layer color design document is created
|
||||||
|
- Active layer background color is defined
|
||||||
|
- Inactive layer background color is defined
|
||||||
|
- Theme tokens are designed for layer backgrounds
|
||||||
|
- Colors work with existing theme system
|
||||||
|
- Implementation plan is provided
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Review design document for clarity
|
||||||
|
- Verify colors work with existing themes
|
||||||
|
- Check that colors are accessible and readable
|
||||||
|
- Ensure colors work in both light and dark modes
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing theme tokens where possible
|
||||||
|
- Ensure contrast ratios meet accessibility standards
|
||||||
|
- Colors should be subtle but clearly visible
|
||||||
|
- Consider terminal color limitations
|
||||||
|
- Design should be consistent with existing UI elements
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 09. Create Theme Context Provider
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-09
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [theming, implementation, solid-js]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Create theme context provider based on opencode implementation
|
||||||
|
- Implement theme state management
|
||||||
|
- Provide theme tokens to all components
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Theme context provider component
|
||||||
|
- Theme state management hooks
|
||||||
|
- Theme provider integration
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read opencode/packages/ui/src/theme/context.tsx for reference
|
||||||
|
- Create theme context using SolidJS createContext
|
||||||
|
- Design theme state structure (themeId, colorScheme, mode, etc.)
|
||||||
|
- Implement theme state management with signals
|
||||||
|
- Add theme selection and color scheme management functions
|
||||||
|
- Create ThemeProvider component
|
||||||
|
- Add theme persistence to localStorage
|
||||||
|
- Implement system theme detection
|
||||||
|
- Add theme change listeners
|
||||||
|
- Test theme context provider
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme context provider with mocked state
|
||||||
|
- Integration: Test theme provider integration
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Theme context provider is created
|
||||||
|
- Theme state management works correctly
|
||||||
|
- Theme selection and color scheme management functions work
|
||||||
|
- Theme persistence to localStorage works
|
||||||
|
- System theme detection works
|
||||||
|
- Theme change listeners work
|
||||||
|
- Theme provider can be used to wrap App component
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and verify theme context provider works
|
||||||
|
- Test theme selection functionality
|
||||||
|
- Test color scheme switching
|
||||||
|
- Verify localStorage persistence
|
||||||
|
- Check system theme detection
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Follow opencode theming implementation patterns
|
||||||
|
- Use createSignal for reactive theme state
|
||||||
|
- Ensure proper cleanup in onCleanup
|
||||||
|
- Test with different theme configurations
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 10. Implement DesktopTheme Type and Structure
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-10
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-09]
|
||||||
|
tags: [theming, implementation, types]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement DesktopTheme type and structure based on opencode
|
||||||
|
- Define theme data structure for light and dark variants
|
||||||
|
- Create theme token types
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- DesktopTheme type definition
|
||||||
|
- Theme variant structure
|
||||||
|
- Token type definitions
|
||||||
|
- Example theme data
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read opencode/packages/ui/src/theme/types.ts for reference
|
||||||
|
- Create DesktopTheme type interface
|
||||||
|
- Define ThemeVariant structure with seeds and overrides
|
||||||
|
- Define ThemeColor type
|
||||||
|
- Define ColorScheme type
|
||||||
|
- Define ResolvedTheme type
|
||||||
|
- Define ColorValue type
|
||||||
|
- Create theme constants file
|
||||||
|
- Add example theme data (system, catppuccin, gruvbox, tokyo, nord)
|
||||||
|
- Test type definitions
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test type definitions with TypeScript compiler
|
||||||
|
- Integration: None (type definition task)
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- DesktopTheme type is defined
|
||||||
|
- ThemeVariant structure is defined
|
||||||
|
- ThemeColor type is defined
|
||||||
|
- ColorScheme type is defined
|
||||||
|
- ResolvedTheme type is defined
|
||||||
|
- ColorValue type is defined
|
||||||
|
- Example theme data is provided
|
||||||
|
- All types are exported correctly
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `tsc --noEmit` to verify no TypeScript errors
|
||||||
|
- Test theme type usage in components
|
||||||
|
- Verify theme data structure is correct
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Follow opencode theming implementation patterns
|
||||||
|
- Ensure types are comprehensive and well-documented
|
||||||
|
- Add JSDoc comments for complex types
|
||||||
|
- Consider TypeScript strict mode compliance
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 11. Implement Theme Resolution System
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-11
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-10]
|
||||||
|
tags: [theming, implementation, theme-resolution]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement theme resolution system for light and dark variants
|
||||||
|
- Create theme token generation logic
|
||||||
|
- Define color scale generation functions
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Theme resolution functions
|
||||||
|
- Color scale generation functions
|
||||||
|
- Theme token generation logic
|
||||||
|
- Theme CSS variable generation
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read opencode/packages/ui/src/theme/resolve.ts for reference
|
||||||
|
- Implement resolveThemeVariant function
|
||||||
|
- Implement generateNeutralScale function
|
||||||
|
- Implement generateScale function
|
||||||
|
- Implement hexToOklch and oklchToHex functions
|
||||||
|
- Implement withAlpha function
|
||||||
|
- Implement themeToCss function
|
||||||
|
- Create theme resolution constants
|
||||||
|
- Define color scale configurations
|
||||||
|
- Implement theme resolution for each theme
|
||||||
|
- Test theme resolution functions
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme resolution with mocked themes
|
||||||
|
- Integration: Test theme resolution in context provider
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- resolveThemeVariant function works correctly
|
||||||
|
- generateNeutralScale function works correctly
|
||||||
|
- generateScale function works correctly
|
||||||
|
- Color conversion functions work correctly
|
||||||
|
- themeToCss function generates valid CSS
|
||||||
|
- Theme resolution works for all themes
|
||||||
|
- Light and dark variants are resolved correctly
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and verify theme resolution works
|
||||||
|
- Test theme resolution for all themes
|
||||||
|
- Verify light/dark variants are correct
|
||||||
|
- Check CSS variable generation
|
||||||
|
- Test with different color schemes
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Follow opencode theming implementation patterns
|
||||||
|
- Ensure color scales are generated correctly
|
||||||
|
- Test color conversion functions
|
||||||
|
- Verify theme tokens match expected values
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 12. Create CSS Variable Token System
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-12
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P3
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-11]
|
||||||
|
tags: [theming, implementation, css-variables]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Create comprehensive CSS variable token system
|
||||||
|
- Define all theme tokens for OpenTUI components
|
||||||
|
- Generate CSS variables from theme tokens
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- CSS variable token definitions
|
||||||
|
- Theme CSS generation functions
|
||||||
|
- CSS variable application utilities
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read opencode/packages/ui/src/theme/resolve.ts for reference
|
||||||
|
- Review opencode theme token definitions
|
||||||
|
- Define theme tokens for OpenTUI components:
|
||||||
|
- Background tokens (background-base, background-weak, etc.)
|
||||||
|
- Surface tokens (surface-base, surface-raised, etc.)
|
||||||
|
- Text tokens (text-base, text-weak, etc.)
|
||||||
|
- Border tokens (border-base, border-hover, etc.)
|
||||||
|
- Interactive tokens (interactive-base, interactive-hover, etc.)
|
||||||
|
- Color tokens for specific states (success, warning, error, etc.)
|
||||||
|
- Layer navigation tokens (layer-active-bg, layer-inactive-bg, etc.)
|
||||||
|
- Implement themeToCss function to generate CSS variables
|
||||||
|
- Create utility to apply theme tokens to components
|
||||||
|
- Test CSS variable generation
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test CSS variable generation
|
||||||
|
- Integration: Test CSS variable application in components
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All OpenTUI theme tokens are defined
|
||||||
|
- CSS variable generation works correctly
|
||||||
|
- Theme tokens can be applied to components
|
||||||
|
- Generated CSS is valid
|
||||||
|
- Tokens cover all component styling needs
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and verify CSS variables are applied
|
||||||
|
- Test theme token application in components
|
||||||
|
- Check CSS variable values
|
||||||
|
- Verify tokens work with existing components
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Follow opencode theming implementation patterns
|
||||||
|
- Ensure tokens are comprehensive and well-organized
|
||||||
|
- Test token application in various components
|
||||||
|
- Consider backward compatibility with existing hardcoded colors
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 13. Implement System Theme Detection
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-13
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-12]
|
||||||
|
tags: [theming, implementation, system-theme]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement system theme detection (prefers-color-scheme)
|
||||||
|
- Add theme mode management (light/dark/auto)
|
||||||
|
- Implement automatic theme switching based on system preferences
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- System theme detection implementation
|
||||||
|
- Theme mode management functions
|
||||||
|
- Automatic theme switching logic
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read opencode/packages/ui/src/theme/context.tsx for reference
|
||||||
|
- Implement getSystemMode function
|
||||||
|
- Add theme mode state to theme context
|
||||||
|
- Implement colorScheme state management
|
||||||
|
- Add window.matchMedia listener for system theme changes
|
||||||
|
- Implement automatic mode switching when colorScheme is "system"
|
||||||
|
- Add theme mode persistence to localStorage
|
||||||
|
- Test system theme detection
|
||||||
|
- Test automatic theme switching
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test system theme detection with mocked media queries
|
||||||
|
- Integration: Test theme mode switching
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- getSystemMode function works correctly
|
||||||
|
- Theme mode state is managed correctly
|
||||||
|
- Window.matchMedia listener works correctly
|
||||||
|
- Automatic theme switching works when colorScheme is "system"
|
||||||
|
- Theme mode persistence to localStorage works
|
||||||
|
- System theme changes are detected and applied
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and test system theme detection
|
||||||
|
- Test switching system between light/dark modes
|
||||||
|
- Verify automatic theme switching works
|
||||||
|
- Check localStorage persistence
|
||||||
|
- Test theme mode selection (system/light/dark)
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Follow opencode theming implementation patterns
|
||||||
|
- Ensure proper cleanup in onCleanup
|
||||||
|
- Test with different system theme settings
|
||||||
|
- Verify theme updates are reactive
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 14. Integrate Theme Provider into App Component
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-14
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-09, podtui-navigation-theming-improvements-13]
|
||||||
|
tags: [integration, theming, app]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Integrate theme provider into App component
|
||||||
|
- Apply theme tokens to all components
|
||||||
|
- Ensure theme changes are reactive
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Updated App.tsx with theme provider
|
||||||
|
- Theme application logic
|
||||||
|
- Theme integration tests
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Read references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Wrap App component with ThemeProvider
|
||||||
|
- Implement theme application in Layout component
|
||||||
|
- Apply theme tokens to all components:
|
||||||
|
- TabNavigation (active tab background)
|
||||||
|
- Navigation (footer navigation)
|
||||||
|
- DiscoverPage (background, borders, text colors)
|
||||||
|
- FeedList (background, borders, text colors)
|
||||||
|
- SettingsScreen (background, borders, text colors)
|
||||||
|
- SourceManager (background, borders, text colors)
|
||||||
|
- All other components
|
||||||
|
- Update theme tokens usage in src/constants/themes.ts
|
||||||
|
- Test theme application in all components
|
||||||
|
- Test theme changes are reactive
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme provider integration
|
||||||
|
- Integration: Test theme changes in all components
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- ThemeProvider is integrated into App component
|
||||||
|
- Theme tokens are applied to all components
|
||||||
|
- Theme changes are reactive
|
||||||
|
- All components render correctly with theme tokens
|
||||||
|
- No console errors related to theming
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and verify theme is applied
|
||||||
|
- Test theme selection and color scheme switching
|
||||||
|
- Test system theme detection
|
||||||
|
- Verify all components use theme tokens
|
||||||
|
- Check console for errors
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Follow opencode theming implementation patterns
|
||||||
|
- Ensure theme tokens are used consistently
|
||||||
|
- Test theme changes in all components
|
||||||
|
- Verify no hardcoded colors remain
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 15. Update Components to Use Theme Tokens
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-15
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P3
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-14]
|
||||||
|
tags: [theming, implementation, component-updates]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Replace all hardcoded colors with theme tokens
|
||||||
|
- Update all components to use theme context
|
||||||
|
- Ensure consistent theme application across all components
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Updated components using theme tokens
|
||||||
|
- Theme token usage guide
|
||||||
|
- List of components updated
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Review all components in src/components/
|
||||||
|
- Identify all hardcoded color values
|
||||||
|
- Replace hardcoded colors with theme tokens:
|
||||||
|
- Background colors (background-base, surface-base, etc.)
|
||||||
|
- Text colors (text-base, text-weak, etc.)
|
||||||
|
- Border colors (border-base, border-hover, etc.)
|
||||||
|
- Interactive colors (interactive-base, interactive-hover, etc.)
|
||||||
|
- Color tokens (success, warning, error, etc.)
|
||||||
|
- Layer navigation colors (layer-active-bg, layer-inactive-bg, etc.)
|
||||||
|
- Update src/constants/themes.ts to export theme tokens
|
||||||
|
- Update all components to use theme context
|
||||||
|
- Test theme tokens in all components
|
||||||
|
- Verify no hardcoded colors remain
|
||||||
|
- Create theme token usage guide
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme tokens in individual components
|
||||||
|
- Integration: Test theme tokens in all components
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All hardcoded colors are replaced with theme tokens
|
||||||
|
- All components use theme tokens
|
||||||
|
- Theme tokens are exported from themes.ts
|
||||||
|
- Theme token usage guide is created
|
||||||
|
- No console errors related to theming
|
||||||
|
- All components render correctly with theme tokens
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and verify all components use theme tokens
|
||||||
|
- Test theme selection and color scheme switching
|
||||||
|
- Verify all components render correctly
|
||||||
|
- Check console for errors
|
||||||
|
- Verify no hardcoded colors remain
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use references/solid/REFERENCE.md for SolidJS patterns
|
||||||
|
- Follow opencode theming implementation patterns
|
||||||
|
- Ensure consistent theme application across all components
|
||||||
|
- Test theme tokens in all components
|
||||||
|
- Verify backward compatibility if needed
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 16. Test Navigation Flows and Layer Transitions
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-16
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-06, podtui-navigation-theming-improvements-07, podtui-navigation-theming-improvements-08]
|
||||||
|
tags: [testing, navigation, integration]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Test all navigation flows and layer transitions
|
||||||
|
- Verify left/right navigation works correctly
|
||||||
|
- Verify enter/escape navigation works correctly
|
||||||
|
- Test layer transitions between different pages
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Navigation test results
|
||||||
|
- Test cases for navigation flows
|
||||||
|
- Bug reports for any issues
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Create test cases for navigation flows:
|
||||||
|
- Test left/right navigation between layers
|
||||||
|
- Test enter to go down into layers
|
||||||
|
- Test escape to go up from layers
|
||||||
|
- Test layer boundaries (first/last layer)
|
||||||
|
- Test layer nesting behavior
|
||||||
|
- Test navigation with different terminal sizes
|
||||||
|
- Run `bun run start` and perform all test cases
|
||||||
|
- Document any issues or bugs
|
||||||
|
- Test navigation in all pages:
|
||||||
|
- Discover tab
|
||||||
|
- My Feeds tab
|
||||||
|
- Search tab
|
||||||
|
- Player tab
|
||||||
|
- Settings tab
|
||||||
|
- Test keyboard shortcut conflicts
|
||||||
|
- Test visual feedback for navigation
|
||||||
|
- Test layer color visibility
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test navigation logic with mocked state
|
||||||
|
- Integration: Test navigation flows in actual application
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All navigation flows work correctly
|
||||||
|
- Left/right navigation works between layers
|
||||||
|
- Enter/escape navigation works correctly
|
||||||
|
- Layer boundaries are handled correctly
|
||||||
|
- No keyboard shortcut conflicts
|
||||||
|
- Visual feedback is clear and accurate
|
||||||
|
- All pages work correctly with navigation
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and perform all test cases
|
||||||
|
- Document all test results
|
||||||
|
- Report any issues found
|
||||||
|
- Verify all navigation flows work
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Test with different terminal sizes
|
||||||
|
- Test with different layer depths
|
||||||
|
- Test keyboard shortcuts in all pages
|
||||||
|
- Verify visual feedback is clear
|
||||||
|
- Test edge cases and error conditions
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 17. Test Tab Crash Fixes and Edge Cases
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-17
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P1
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-02, podtui-navigation-theming-improvements-03, podtui-navigation-theming-improvements-04]
|
||||||
|
tags: [testing, crash-fix, integration]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Test all tab crash fixes
|
||||||
|
- Verify Discover, My Feeds, and Settings tabs load without errors
|
||||||
|
- Test edge cases and error conditions
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Crash fix test results
|
||||||
|
- Test cases for tab crashes
|
||||||
|
- Bug reports for any remaining issues
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Create test cases for tab crashes:
|
||||||
|
- Test Discover tab selection
|
||||||
|
- Test My Feeds tab selection
|
||||||
|
- Test Settings tab selection
|
||||||
|
- Test Settings/Sources sub-tab selection
|
||||||
|
- Test navigation between tabs
|
||||||
|
- Run `bun run start` and perform all test cases
|
||||||
|
- Test all keyboard shortcuts in each tab
|
||||||
|
- Test edge cases:
|
||||||
|
- Empty feed lists
|
||||||
|
- Missing data
|
||||||
|
- Network errors
|
||||||
|
- Invalid inputs
|
||||||
|
- Rapid tab switching
|
||||||
|
- Test error boundaries
|
||||||
|
- Document any remaining issues
|
||||||
|
- Verify no console errors
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test components with mocked data
|
||||||
|
- Integration: Test all tabs and sub-tabs in actual application
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All tabs load without crashes
|
||||||
|
- No console errors when selecting tabs
|
||||||
|
- All keyboard shortcuts work correctly
|
||||||
|
- Edge cases are handled gracefully
|
||||||
|
- Error boundaries work correctly
|
||||||
|
- All test cases pass
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and perform all test cases
|
||||||
|
- Check console for errors
|
||||||
|
- Test all keyboard shortcuts
|
||||||
|
- Test edge cases
|
||||||
|
- Document all test results
|
||||||
|
- Report any remaining issues
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Test with different data states (empty, full, partial)
|
||||||
|
- Test network error scenarios
|
||||||
|
- Test rapid user interactions
|
||||||
|
- Verify error messages are clear
|
||||||
|
- Test with different terminal sizes
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 18. Test Theming System with All Modes
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: podtui-navigation-theming-improvements-18
|
||||||
|
feature: podtui-navigation-theming-improvements
|
||||||
|
priority: P2
|
||||||
|
depends_on: [podtui-navigation-theming-improvements-15, podtui-navigation-theming-improvements-16, podtui-navigation-theming-improvements-17]
|
||||||
|
tags: [testing, theming, integration]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Test theming system with all modes (system/light/dark)
|
||||||
|
- Verify theme tokens work correctly
|
||||||
|
- Ensure theming is consistent across all components
|
||||||
|
- Test theme persistence and system theme detection
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Theming system test results
|
||||||
|
- Theme token test cases
|
||||||
|
- Theme consistency report
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Create test cases for theming system:
|
||||||
|
- Test system theme mode
|
||||||
|
- Test light theme mode
|
||||||
|
- Test dark theme mode
|
||||||
|
- Test theme persistence
|
||||||
|
- Test system theme detection
|
||||||
|
- Test theme selection
|
||||||
|
- Test color scheme switching
|
||||||
|
- Run `bun run start` and perform all test cases
|
||||||
|
- Test theme tokens in all components:
|
||||||
|
- Background colors
|
||||||
|
- Text colors
|
||||||
|
- Border colors
|
||||||
|
- Interactive colors
|
||||||
|
- Color tokens (success, warning, error, etc.)
|
||||||
|
- Layer navigation colors
|
||||||
|
- Test theme changes are reactive
|
||||||
|
- Test theme tokens work in all terminals
|
||||||
|
- Verify theme consistency across all components
|
||||||
|
- Document any issues
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test theme tokens with mocked themes
|
||||||
|
- Integration: Test theming in all components and modes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Theming system works correctly in all modes
|
||||||
|
- Theme tokens work correctly in all components
|
||||||
|
- Theme changes are reactive
|
||||||
|
- Theme persistence works correctly
|
||||||
|
- System theme detection works correctly
|
||||||
|
- Theme consistency is maintained across all components
|
||||||
|
- All test cases pass
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun run start` and perform all test cases
|
||||||
|
- Test all theme modes (system/light/dark)
|
||||||
|
- Test theme selection and color scheme switching
|
||||||
|
- Verify theme persistence
|
||||||
|
- Test system theme detection
|
||||||
|
- Check console for errors
|
||||||
|
- Verify theme consistency across all components
|
||||||
|
- Document all test results
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Test with different terminal sizes
|
||||||
|
- Test with different color schemes
|
||||||
|
- Test rapid theme changes
|
||||||
|
- Verify theme updates are reactive
|
||||||
|
- Test all components with all themes
|
||||||
|
- Ensure accessibility standards are met
|
||||||
50
tasks/podtui-navigation-theming-improvements/README.md
Normal file
50
tasks/podtui-navigation-theming-improvements/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# PodTUI Navigation and Theming Improvements
|
||||||
|
|
||||||
|
Objective: Implement layered navigation system, fix tab crashes, and integrate sophisticated theming based on opencode
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
- [ ] 01 — Analyze current navigation and layer system → `01-analyze-navigation-system.md`
|
||||||
|
- [ ] 02 — Fix Discover tab crash → `02-fix-discover-tab-crash.md`
|
||||||
|
- [ ] 03 — Fix My Feeds tab crash → `03-fix-feeds-tab-crash.md`
|
||||||
|
- [ ] 04 — Fix Settings/Sources sub-tab crash → `04-fix-settings-sources-crash.md`
|
||||||
|
- [ ] 05 — Design layered navigation UI system → `05-design-layered-navigation-ui.md`
|
||||||
|
- [ ] 06 — Implement left/right layer navigation controls → `06-implement-layer-navigation-controls.md`
|
||||||
|
- [ ] 07 — Implement enter/escape layer navigation controls → `07-implement-enter-escape-controls.md`
|
||||||
|
- [ ] 08 — Design active layer background color system → `08-design-active-layer-colors.md`
|
||||||
|
- [ ] 09 — Create theme context provider → `09-create-theme-context-provider.md`
|
||||||
|
- [ ] 10 — Implement DesktopTheme type and structure → `10-implement-desktop-theme-types.md`
|
||||||
|
- [ ] 11 — Implement theme resolution system → `11-implement-theme-resolution.md`
|
||||||
|
- [ ] 12 — Create CSS variable token system → `12-create-css-token-system.md`
|
||||||
|
- [ ] 13 — Implement system theme detection → `13-implement-system-theme-detection.md`
|
||||||
|
- [ ] 14 — Integrate theme provider into App component → `14-integrate-theme-provider.md`
|
||||||
|
- [ ] 15 — Update components to use theme tokens → `15-update-components-to-use-themes.md`
|
||||||
|
- [ ] 16 — Test navigation flows and layer transitions → `16-test-navigation-flows.md`
|
||||||
|
- [ ] 17 — Test tab crash fixes and edge cases → `17-test-tab-crash-fixes.md`
|
||||||
|
- [ ] 18 — Test theming system with all modes → `18-test-theming-system.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
- 01 depends on
|
||||||
|
- 02, 03, 04 depends on 01
|
||||||
|
- 05 depends on 02, 03, 04
|
||||||
|
- 06, 07, 08 depends on 05
|
||||||
|
- 16 depends on 06, 07, 08
|
||||||
|
- 09 depends on
|
||||||
|
- 10 depends on 09
|
||||||
|
- 11 depends on 10
|
||||||
|
- 12 depends on 11
|
||||||
|
- 13 depends on 12
|
||||||
|
- 14 depends on 13
|
||||||
|
- 15 depends on 14
|
||||||
|
- 18 depends on 15, 16, 17
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
- Navigation is clearly visualized with layered backgrounds and active states
|
||||||
|
- Left/right keys navigate between layers, enter goes down, escape goes up
|
||||||
|
- All tabs (Discover, My Feeds, Settings) load without crashes
|
||||||
|
- Settings/Sources sub-tab loads without crashes
|
||||||
|
- Theming system works correctly with system/light/dark/auto modes
|
||||||
|
- All components use theme tokens consistently
|
||||||
|
- No hardcoded colors remain in components
|
||||||
|
- All tests pass and crashes are resolved
|
||||||
Reference in New Issue
Block a user