Compare commits
5 Commits
f08afb2ed1
...
72b2870f64
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b2870f64 | |||
| f7df578461 | |||
| bd4747679d | |||
| d5ce8452e4 | |||
| 7b5c256e07 |
11
.eslintrc.cjs
Normal file
11
.eslintrc.cjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
plugins: ["@typescript-eslint"],
|
||||||
|
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
env: {
|
||||||
|
es2022: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ["dist", "node_modules"],
|
||||||
|
}
|
||||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -1,2 +1,35 @@
|
|||||||
.opencode
|
.opencode
|
||||||
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|||||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# solid
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.
|
||||||
43
build.ts
Normal file
43
build.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||||
|
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
|
||||||
|
import { join, dirname } from "node:path"
|
||||||
|
|
||||||
|
// Build the JavaScript bundle
|
||||||
|
await Bun.build({
|
||||||
|
entrypoints: ["./src/index.tsx"],
|
||||||
|
outdir: "./dist",
|
||||||
|
target: "bun",
|
||||||
|
minify: true,
|
||||||
|
sourcemap: "external",
|
||||||
|
plugins: [solidPlugin],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Copy the native library to dist for distribution
|
||||||
|
const platform = process.platform
|
||||||
|
const arch = process.arch
|
||||||
|
|
||||||
|
// Map platform/arch to OpenTUI package names
|
||||||
|
const platformMap: Record<string, string> = {
|
||||||
|
"darwin-arm64": "darwin-arm64",
|
||||||
|
"darwin-x64": "darwin-x64",
|
||||||
|
"linux-x64": "linux-x64",
|
||||||
|
"linux-arm64": "linux-arm64",
|
||||||
|
"win32-x64": "win32-x64",
|
||||||
|
"win32-arm64": "win32-arm64",
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformKey = `${platform}-${arch}`
|
||||||
|
const platformPkg = platformMap[platformKey]
|
||||||
|
|
||||||
|
if (platformPkg) {
|
||||||
|
const libName = platform === "win32" ? "opentui.dll" : "libopentui.dylib"
|
||||||
|
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName)
|
||||||
|
|
||||||
|
if (existsSync(srcPath)) {
|
||||||
|
const destPath = join("dist", libName)
|
||||||
|
copyFileSync(srcPath, destPath)
|
||||||
|
console.log(`Copied native library: ${libName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Build complete")
|
||||||
1
bunfig.toml
Normal file
1
bunfig.toml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
preload = ["@opentui/solid/preload"]
|
||||||
38
package.json
38
package.json
@@ -1 +1,37 @@
|
|||||||
{ "dependencies": { "@opentui/core": "^0.1.77" } }
|
{
|
||||||
|
"name": "podcast-tui-app",
|
||||||
|
"module": "src/index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"bin": {
|
||||||
|
"podtui": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun src/index.tsx",
|
||||||
|
"dev": "bun --watch src/index.tsx",
|
||||||
|
"build": "bun run build.ts",
|
||||||
|
"dist": "bun dist/index.js",
|
||||||
|
"test": "bun test",
|
||||||
|
"lint": "bun run lint.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@opentui/react": "^0.1.77",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.28.5",
|
||||||
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
|
"@opentui/core": "^0.1.77",
|
||||||
|
"@opentui/solid": "^0.1.77",
|
||||||
|
"babel-preset-solid": "1.9.9",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"solid-js": "^1.9.11",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
182
src/App.tsx
Normal file
182
src/App.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { Layout } from "./components/Layout"
|
||||||
|
import { Navigation } from "./components/Navigation"
|
||||||
|
import { TabNavigation } from "./components/TabNavigation"
|
||||||
|
import { SyncPanel } from "./components/SyncPanel"
|
||||||
|
import { FeedList } from "./components/FeedList"
|
||||||
|
import { LoginScreen } from "./components/LoginScreen"
|
||||||
|
import { CodeValidation } from "./components/CodeValidation"
|
||||||
|
import { OAuthPlaceholder } from "./components/OAuthPlaceholder"
|
||||||
|
import { SyncProfile } from "./components/SyncProfile"
|
||||||
|
import { SearchPage } from "./components/SearchPage"
|
||||||
|
import { DiscoverPage } from "./components/DiscoverPage"
|
||||||
|
import { useAuthStore } from "./stores/auth"
|
||||||
|
import { useFeedStore } from "./stores/feed"
|
||||||
|
import { FeedVisibility } from "./types/feed"
|
||||||
|
import { useAppKeyboard } from "./hooks/useAppKeyboard"
|
||||||
|
import type { TabId } from "./components/Tab"
|
||||||
|
import type { AuthScreen } from "./types/auth"
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [activeTab, setActiveTab] = createSignal<TabId>("discover")
|
||||||
|
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login")
|
||||||
|
const [showAuthPanel, setShowAuthPanel] = createSignal(false)
|
||||||
|
const [inputFocused, setInputFocused] = createSignal(false)
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const feedStore = useFeedStore()
|
||||||
|
|
||||||
|
// Centralized keyboard handler for all tab navigation and shortcuts
|
||||||
|
useAppKeyboard({
|
||||||
|
get activeTab() { return activeTab() },
|
||||||
|
onTabChange: setActiveTab,
|
||||||
|
inputFocused: inputFocused(),
|
||||||
|
onAction: (action) => {
|
||||||
|
if (action === "escape") {
|
||||||
|
setShowAuthPanel(false)
|
||||||
|
setInputFocused(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
const tab = activeTab()
|
||||||
|
|
||||||
|
switch (tab) {
|
||||||
|
case "feeds":
|
||||||
|
return (
|
||||||
|
<FeedList
|
||||||
|
focused={true}
|
||||||
|
showEpisodeCount={true}
|
||||||
|
showLastUpdated={true}
|
||||||
|
onOpenFeed={(feed) => {
|
||||||
|
// Would open feed detail view
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "settings":
|
||||||
|
// Show auth panel or sync panel based on state
|
||||||
|
if (showAuthPanel()) {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<SyncProfile
|
||||||
|
focused={true}
|
||||||
|
onLogout={() => {
|
||||||
|
auth.logout()
|
||||||
|
setShowAuthPanel(false)
|
||||||
|
}}
|
||||||
|
onManageSync={() => setShowAuthPanel(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (authScreen()) {
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<CodeValidation
|
||||||
|
focused={true}
|
||||||
|
onBack={() => setAuthScreen("login")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "oauth":
|
||||||
|
return (
|
||||||
|
<OAuthPlaceholder
|
||||||
|
focused={true}
|
||||||
|
onBack={() => setAuthScreen("login")}
|
||||||
|
onNavigateToCode={() => setAuthScreen("code")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "login":
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<LoginScreen
|
||||||
|
focused={true}
|
||||||
|
onNavigateToCode={() => setAuthScreen("code")}
|
||||||
|
onNavigateToOAuth={() => setAuthScreen("oauth")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<SyncPanel />
|
||||||
|
<box height={1} />
|
||||||
|
<box border padding={1}>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg="gray">Account:</text>
|
||||||
|
{auth.isAuthenticated ? (
|
||||||
|
<text fg="green">Signed in as {auth.user?.email}</text>
|
||||||
|
) : (
|
||||||
|
<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":
|
||||||
|
return (
|
||||||
|
<DiscoverPage focused={!inputFocused()} />
|
||||||
|
)
|
||||||
|
|
||||||
|
case "search":
|
||||||
|
return (
|
||||||
|
<SearchPage
|
||||||
|
focused={!inputFocused()}
|
||||||
|
onInputFocusChange={setInputFocused}
|
||||||
|
onSubscribe={(result) => {
|
||||||
|
const feeds = feedStore.feeds()
|
||||||
|
const alreadySubscribed = feeds.some(
|
||||||
|
(feed) =>
|
||||||
|
feed.podcast.id === result.podcast.id ||
|
||||||
|
feed.podcast.feedUrl === result.podcast.feedUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadySubscribed) {
|
||||||
|
feedStore.addFeed(
|
||||||
|
{ ...result.podcast, isSubscribed: true },
|
||||||
|
result.sourceId,
|
||||||
|
FeedVisibility.PUBLIC
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "player":
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<box border style={{ padding: 2 }}>
|
||||||
|
<text>
|
||||||
|
<strong>{tab}</strong>
|
||||||
|
<br />
|
||||||
|
Player - coming in later phases
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box style={{ padding: 1 }}>{renderContent()}</box>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/components/BoxLayout.tsx
Normal file
42
src/components/BoxLayout.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
|
||||||
|
type BoxLayoutProps = {
|
||||||
|
children?: JSX.Element
|
||||||
|
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
|
||||||
|
justifyContent?:
|
||||||
|
| "flex-start"
|
||||||
|
| "flex-end"
|
||||||
|
| "center"
|
||||||
|
| "space-between"
|
||||||
|
| "space-around"
|
||||||
|
| "space-evenly"
|
||||||
|
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||||
|
gap?: number
|
||||||
|
width?: number | "auto" | `${number}%`
|
||||||
|
height?: number | "auto" | `${number}%`
|
||||||
|
padding?: number
|
||||||
|
margin?: number
|
||||||
|
border?: boolean
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoxLayout(props: BoxLayoutProps) {
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
style={{
|
||||||
|
flexDirection: props.flexDirection,
|
||||||
|
justifyContent: props.justifyContent,
|
||||||
|
alignItems: props.alignItems,
|
||||||
|
gap: props.gap,
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
padding: props.padding,
|
||||||
|
margin: props.margin,
|
||||||
|
}}
|
||||||
|
border={props.border}
|
||||||
|
title={props.title}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/CategoryFilter.tsx
Normal file
40
src/components/CategoryFilter.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* CategoryFilter component - Horizontal category filter tabs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For } from "solid-js"
|
||||||
|
import type { DiscoverCategory } from "../stores/discover"
|
||||||
|
|
||||||
|
type CategoryFilterProps = {
|
||||||
|
categories: DiscoverCategory[]
|
||||||
|
selectedCategory: string
|
||||||
|
focused: boolean
|
||||||
|
onSelect?: (categoryId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryFilter(props: CategoryFilterProps) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={1} flexWrap="wrap">
|
||||||
|
<For each={props.categories}>
|
||||||
|
{(category) => {
|
||||||
|
const isSelected = () => props.selectedCategory === category.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
border={isSelected()}
|
||||||
|
backgroundColor={isSelected() ? "#444" : undefined}
|
||||||
|
onMouseDown={() => props.onSelect?.(category.id)}
|
||||||
|
>
|
||||||
|
<text fg={isSelected() ? "cyan" : "gray"}>
|
||||||
|
{category.icon} {category.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
src/components/CodeValidation.tsx
Normal file
172
src/components/CodeValidation.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Code validation component for PodTUI
|
||||||
|
* 8-character alphanumeric code input for sync authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useAuthStore } from "../stores/auth"
|
||||||
|
import { AUTH_CONFIG } from "../config/auth"
|
||||||
|
|
||||||
|
interface CodeValidationProps {
|
||||||
|
focused?: boolean
|
||||||
|
onBack?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusField = "code" | "submit" | "back"
|
||||||
|
|
||||||
|
export function CodeValidation(props: CodeValidationProps) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const [code, setCode] = createSignal("")
|
||||||
|
const [focusField, setFocusField] = createSignal<FocusField>("code")
|
||||||
|
const [codeError, setCodeError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const fields: FocusField[] = ["code", "submit", "back"]
|
||||||
|
|
||||||
|
/** Format code as user types (uppercase, alphanumeric only) */
|
||||||
|
const handleCodeInput = (value: string) => {
|
||||||
|
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||||
|
// Limit to max length
|
||||||
|
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength)
|
||||||
|
setCode(limited)
|
||||||
|
|
||||||
|
// Clear error when typing
|
||||||
|
if (codeError()) {
|
||||||
|
setCodeError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateCode = (value: string): boolean => {
|
||||||
|
if (!value) {
|
||||||
|
setCodeError("Code is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
|
||||||
|
setCodeError(`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
|
||||||
|
setCodeError("Code must contain only letters and numbers")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setCodeError(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateCode(code())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await auth.validateCode(code())
|
||||||
|
if (!success && auth.error) {
|
||||||
|
setCodeError(auth.error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const currentIndex = fields.indexOf(focusField())
|
||||||
|
const nextIndex = key.shift
|
||||||
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
|
: (currentIndex + 1) % fields.length
|
||||||
|
setFocusField(fields[nextIndex])
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
if (focusField() === "submit") {
|
||||||
|
handleSubmit()
|
||||||
|
} else if (focusField() === "back" && props.onBack) {
|
||||||
|
props.onBack()
|
||||||
|
}
|
||||||
|
} else if (key.name === "escape" && props.onBack) {
|
||||||
|
props.onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeProgress = () => {
|
||||||
|
const len = code().length
|
||||||
|
const max = AUTH_CONFIG.codeValidation.codeLength
|
||||||
|
return `${len}/${max}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeDisplay = () => {
|
||||||
|
const current = code()
|
||||||
|
const max = AUTH_CONFIG.codeValidation.codeLength
|
||||||
|
const filled = current.split("")
|
||||||
|
const empty = Array(max - filled.length).fill("_")
|
||||||
|
return [...filled, ...empty].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" border padding={2} gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>Enter Sync Code</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
<text fg="gray">Enter your 8-character sync code to link your account.</text>
|
||||||
|
<text fg="gray">You can get this code from the web portal.</text>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Code display */}
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg={focusField() === "code" ? "cyan" : undefined}>
|
||||||
|
Code ({codeProgress()}):
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box border padding={1}>
|
||||||
|
<text fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}>
|
||||||
|
{codeDisplay()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Hidden input for actual typing */}
|
||||||
|
<input
|
||||||
|
value={code()}
|
||||||
|
onInput={handleCodeInput}
|
||||||
|
placeholder=""
|
||||||
|
focused={props.focused && focusField() === "code"}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{codeError() && (
|
||||||
|
<text fg="red">{codeError()}</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "submit" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||||
|
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||||
|
[Esc] Back to Login
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Auth error message */}
|
||||||
|
{auth.error && (
|
||||||
|
<text fg="red">{auth.error.message}</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/components/Column.tsx
Normal file
35
src/components/Column.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
|
||||||
|
type ColumnProps = {
|
||||||
|
children?: JSX.Element
|
||||||
|
gap?: number
|
||||||
|
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||||
|
justifyContent?:
|
||||||
|
| "flex-start"
|
||||||
|
| "flex-end"
|
||||||
|
| "center"
|
||||||
|
| "space-between"
|
||||||
|
| "space-around"
|
||||||
|
| "space-evenly"
|
||||||
|
width?: number | "auto" | `${number}%`
|
||||||
|
height?: number | "auto" | `${number}%`
|
||||||
|
padding?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Column(props: ColumnProps) {
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: props.gap,
|
||||||
|
alignItems: props.alignItems,
|
||||||
|
justifyContent: props.justifyContent,
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
padding: props.padding,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
src/components/DiscoverPage.tsx
Normal file
177
src/components/DiscoverPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* DiscoverPage component - Main discover/browse interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "../stores/discover"
|
||||||
|
import { CategoryFilter } from "./CategoryFilter"
|
||||||
|
import { TrendingShows } from "./TrendingShows"
|
||||||
|
|
||||||
|
type DiscoverPageProps = {
|
||||||
|
focused: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusArea = "categories" | "shows"
|
||||||
|
|
||||||
|
export function DiscoverPage(props: DiscoverPageProps) {
|
||||||
|
const discoverStore = useDiscoverStore()
|
||||||
|
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows")
|
||||||
|
const [showIndex, setShowIndex] = createSignal(0)
|
||||||
|
const [categoryIndex, setCategoryIndex] = createSignal(0)
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useKeyboard((key) => {
|
||||||
|
if (!props.focused) return
|
||||||
|
|
||||||
|
const area = focusArea()
|
||||||
|
|
||||||
|
// Tab switches focus between categories and shows
|
||||||
|
if (key.name === "tab") {
|
||||||
|
if (key.shift) {
|
||||||
|
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||||
|
} else {
|
||||||
|
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category navigation
|
||||||
|
if (area === "categories") {
|
||||||
|
if (key.name === "left" || key.name === "h") {
|
||||||
|
setCategoryIndex((i) => Math.max(0, i - 1))
|
||||||
|
const cat = DISCOVER_CATEGORIES[categoryIndex()]
|
||||||
|
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||||
|
setShowIndex(0) // Reset show selection when changing category
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "right" || key.name === "l") {
|
||||||
|
setCategoryIndex((i) => Math.min(DISCOVER_CATEGORIES.length - 1, i + 1))
|
||||||
|
const cat = DISCOVER_CATEGORIES[categoryIndex()]
|
||||||
|
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||||
|
setShowIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
// Select category and move to shows
|
||||||
|
setFocusArea("shows")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setFocusArea("shows")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows navigation
|
||||||
|
if (area === "shows") {
|
||||||
|
const shows = discoverStore.filteredPodcasts()
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setShowIndex((i) => Math.min(i + 1, shows.length - 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
const newIndex = showIndex() - 1
|
||||||
|
if (newIndex < 0) {
|
||||||
|
setFocusArea("categories")
|
||||||
|
} else {
|
||||||
|
setShowIndex(newIndex)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
// Subscribe/unsubscribe
|
||||||
|
const podcast = shows[showIndex()]
|
||||||
|
if (podcast) {
|
||||||
|
discoverStore.toggleSubscription(podcast.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh with 'r'
|
||||||
|
if (key.name === "r") {
|
||||||
|
discoverStore.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCategorySelect = (categoryId: string) => {
|
||||||
|
discoverStore.setSelectedCategory(categoryId)
|
||||||
|
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId)
|
||||||
|
if (index >= 0) setCategoryIndex(index)
|
||||||
|
setShowIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowSelect = (index: number) => {
|
||||||
|
setShowIndex(index)
|
||||||
|
setFocusArea("shows")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubscribe = (podcast: { id: string }) => {
|
||||||
|
discoverStore.toggleSubscription(podcast.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%" gap={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<text>
|
||||||
|
<strong>Discover Podcasts</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg="gray">
|
||||||
|
{discoverStore.filteredPodcasts().length} shows
|
||||||
|
</text>
|
||||||
|
<box onMouseDown={() => discoverStore.refresh()}>
|
||||||
|
<text fg="cyan">[R] Refresh</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<box border padding={1}>
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={focusArea() === "categories" ? "cyan" : "gray"}>
|
||||||
|
Categories:
|
||||||
|
</text>
|
||||||
|
<CategoryFilter
|
||||||
|
categories={discoverStore.categories}
|
||||||
|
selectedCategory={discoverStore.selectedCategory()}
|
||||||
|
focused={focusArea() === "categories"}
|
||||||
|
onSelect={handleCategorySelect}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Trending Shows */}
|
||||||
|
<box flexDirection="column" flexGrow={1} border>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
||||||
|
Trending in {
|
||||||
|
DISCOVER_CATEGORIES.find(
|
||||||
|
(c) => c.id === discoverStore.selectedCategory()
|
||||||
|
)?.name ?? "All"
|
||||||
|
}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<TrendingShows
|
||||||
|
podcasts={discoverStore.filteredPodcasts()}
|
||||||
|
selectedIndex={showIndex()}
|
||||||
|
focused={focusArea() === "shows"}
|
||||||
|
isLoading={discoverStore.isLoading()}
|
||||||
|
onSelect={handleShowSelect}
|
||||||
|
onSubscribe={handleSubscribe}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Footer Hints */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg="gray">[Tab] Switch focus</text>
|
||||||
|
<text fg="gray">[j/k] Navigate</text>
|
||||||
|
<text fg="gray">[Enter] Subscribe</text>
|
||||||
|
<text fg="gray">[R] Refresh</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/ExportDialog.tsx
Normal file
36
src/components/ExportDialog.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
|
let current = value
|
||||||
|
return [() => current, (next) => {
|
||||||
|
current = next
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
import { SyncStatus } from "./SyncStatus"
|
||||||
|
|
||||||
|
export function ExportDialog() {
|
||||||
|
const filename = createSignal("podcast-sync.json")
|
||||||
|
const format = createSignal<"json" | "xml">("json")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
|
<text>File:</text>
|
||||||
|
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
||||||
|
</box>
|
||||||
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
|
<text>Format:</text>
|
||||||
|
<tab_select
|
||||||
|
options={[
|
||||||
|
{ name: "JSON", description: "Portable" },
|
||||||
|
{ name: "XML", description: "Structured" },
|
||||||
|
]}
|
||||||
|
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<box border>
|
||||||
|
<text>Export {format[0]()} to {filename[0]()}</text>
|
||||||
|
</box>
|
||||||
|
<SyncStatus />
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
src/components/FeedDetail.tsx
Normal file
172
src/components/FeedDetail.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Feed detail view component for PodTUI
|
||||||
|
* Shows podcast info and episode list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For, Show } from "solid-js"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
interface FeedDetailProps {
|
||||||
|
feed: Feed
|
||||||
|
focused?: boolean
|
||||||
|
onBack?: () => void
|
||||||
|
onPlayEpisode?: (episode: Episode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedDetail(props: FeedDetailProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
|
const [showInfo, setShowInfo] = createSignal(true)
|
||||||
|
|
||||||
|
const episodes = () => {
|
||||||
|
// Sort episodes by publication date (newest first)
|
||||||
|
return [...props.feed.episodes].sort(
|
||||||
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${hrs}h ${mins % 60}m`
|
||||||
|
}
|
||||||
|
return `${mins}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return format(date, "MMM d, yyyy")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string }) => {
|
||||||
|
const eps = episodes()
|
||||||
|
|
||||||
|
if (key.name === "escape" && props.onBack) {
|
||||||
|
props.onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "i") {
|
||||||
|
setShowInfo((v) => !v)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||||
|
} else if (key.name === "down" || key.name === "j") {
|
||||||
|
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1))
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
const episode = eps[selectedIndex()]
|
||||||
|
if (episode && props.onPlayEpisode) {
|
||||||
|
props.onPlayEpisode(episode)
|
||||||
|
}
|
||||||
|
} else if (key.name === "home" || key.name === "g") {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
} else if (key.name === "end") {
|
||||||
|
setSelectedIndex(eps.length - 1)
|
||||||
|
} else if (key.name === "pageup") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 10))
|
||||||
|
} else if (key.name === "pagedown") {
|
||||||
|
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
{/* Header with back button */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<box border padding={0} onMouseDown={props.onBack}>
|
||||||
|
<text fg="cyan">[Esc] Back</text>
|
||||||
|
</box>
|
||||||
|
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)}>
|
||||||
|
<text fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Podcast info section */}
|
||||||
|
<Show when={showInfo()}>
|
||||||
|
<box border padding={1} flexDirection="column" gap={0}>
|
||||||
|
<text>
|
||||||
|
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||||
|
</text>
|
||||||
|
{props.feed.podcast.author && (
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">by</text>
|
||||||
|
<text fg="cyan">{props.feed.podcast.author}</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
<box height={1} />
|
||||||
|
<text fg="gray">
|
||||||
|
{props.feed.podcast.description?.slice(0, 200)}
|
||||||
|
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">Episodes:</text>
|
||||||
|
<text fg="white">{props.feed.episodes.length}</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">Updated:</text>
|
||||||
|
<text fg="white">{formatDate(props.feed.lastUpdated)}</text>
|
||||||
|
</box>
|
||||||
|
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
||||||
|
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||||
|
</text>
|
||||||
|
{props.feed.isPinned && (
|
||||||
|
<text fg="yellow">[Pinned]</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Episodes header */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text>
|
||||||
|
<strong>Episodes</strong>
|
||||||
|
</text>
|
||||||
|
<text fg="gray">({episodes().length} total)</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Episode list */}
|
||||||
|
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
||||||
|
<For each={episodes()}>
|
||||||
|
{(episode, index) => (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSelectedIndex(index())
|
||||||
|
if (props.onPlayEpisode) {
|
||||||
|
props.onPlayEpisode(episode)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||||
|
{index() === selectedIndex() ? ">" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={index() === selectedIndex() ? "white" : undefined}>
|
||||||
|
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
||||||
|
{episode.title}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
||||||
|
<text fg="gray">{formatDuration(episode.duration)}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<text fg="gray">
|
||||||
|
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
src/components/FeedFilter.tsx
Normal file
169
src/components/FeedFilter.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Feed filter component for PodTUI
|
||||||
|
* Toggle and filter options for feed list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||||
|
import type { FeedFilter } from "../types/feed"
|
||||||
|
|
||||||
|
interface FeedFilterProps {
|
||||||
|
filter: FeedFilter
|
||||||
|
focused?: boolean
|
||||||
|
onFilterChange: (filter: FeedFilter) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterField = "visibility" | "sort" | "pinned" | "search"
|
||||||
|
|
||||||
|
export function FeedFilterComponent(props: FeedFilterProps) {
|
||||||
|
const [focusField, setFocusField] = createSignal<FilterField>("visibility")
|
||||||
|
const [searchValue, setSearchValue] = createSignal(props.filter.searchQuery || "")
|
||||||
|
|
||||||
|
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"]
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const currentIndex = fields.indexOf(focusField())
|
||||||
|
const nextIndex = key.shift
|
||||||
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
|
: (currentIndex + 1) % fields.length
|
||||||
|
setFocusField(fields[nextIndex])
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
if (focusField() === "visibility") {
|
||||||
|
cycleVisibility()
|
||||||
|
} else if (focusField() === "sort") {
|
||||||
|
cycleSort()
|
||||||
|
} else if (focusField() === "pinned") {
|
||||||
|
togglePinned()
|
||||||
|
}
|
||||||
|
} else if (key.name === "space") {
|
||||||
|
if (focusField() === "pinned") {
|
||||||
|
togglePinned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleVisibility = () => {
|
||||||
|
const current = props.filter.visibility
|
||||||
|
let next: FeedVisibility | "all"
|
||||||
|
if (current === "all") next = FeedVisibility.PUBLIC
|
||||||
|
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
|
||||||
|
else next = "all"
|
||||||
|
props.onFilterChange({ ...props.filter, visibility: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleSort = () => {
|
||||||
|
const sortOptions: FeedSortField[] = [
|
||||||
|
FeedSortField.UPDATED,
|
||||||
|
FeedSortField.TITLE,
|
||||||
|
FeedSortField.EPISODE_COUNT,
|
||||||
|
FeedSortField.LATEST_EPISODE,
|
||||||
|
]
|
||||||
|
const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField)
|
||||||
|
const nextIndex = (currentIndex + 1) % sortOptions.length
|
||||||
|
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePinned = () => {
|
||||||
|
props.onFilterChange({
|
||||||
|
...props.filter,
|
||||||
|
pinnedOnly: !props.filter.pinnedOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchInput = (value: string) => {
|
||||||
|
setSearchValue(value)
|
||||||
|
props.onFilterChange({ ...props.filter, searchQuery: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityLabel = () => {
|
||||||
|
const vis = props.filter.visibility
|
||||||
|
if (vis === "all") return "All"
|
||||||
|
if (vis === "public") return "Public"
|
||||||
|
return "Private"
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityColor = () => {
|
||||||
|
const vis = props.filter.visibility
|
||||||
|
if (vis === "public") return "green"
|
||||||
|
if (vis === "private") return "yellow"
|
||||||
|
return "white"
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortLabel = () => {
|
||||||
|
const sort = props.filter.sortBy
|
||||||
|
switch (sort) {
|
||||||
|
case "title":
|
||||||
|
return "Title"
|
||||||
|
case "episodeCount":
|
||||||
|
return "Episodes"
|
||||||
|
case "latestEpisode":
|
||||||
|
return "Latest"
|
||||||
|
case "updated":
|
||||||
|
default:
|
||||||
|
return "Updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" border padding={1} gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>Filter Feeds</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={2} flexWrap="wrap">
|
||||||
|
{/* Visibility filter */}
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>Show:</text>
|
||||||
|
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Sort filter */}
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={focusField() === "sort" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusField() === "sort" ? "cyan" : "gray"}>Sort:</text>
|
||||||
|
<text fg="white">{sortLabel()}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Pinned filter */}
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>Pinned:</text>
|
||||||
|
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
||||||
|
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Search box */}
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusField() === "search" ? "cyan" : "gray"}>Search:</text>
|
||||||
|
<input
|
||||||
|
value={searchValue()}
|
||||||
|
onInput={handleSearchInput}
|
||||||
|
placeholder="Filter by name..."
|
||||||
|
focused={props.focused && focusField() === "search"}
|
||||||
|
width={25}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/components/FeedItem.tsx
Normal file
109
src/components/FeedItem.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Feed item component for PodTUI
|
||||||
|
* Displays a single feed/podcast in the list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Feed, FeedVisibility } from "../types/feed"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
interface FeedItemProps {
|
||||||
|
feed: Feed
|
||||||
|
isSelected: boolean
|
||||||
|
showEpisodeCount?: boolean
|
||||||
|
showLastUpdated?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedItem(props: FeedItemProps) {
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return format(date, "MMM d")
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeCount = () => props.feed.episodes.length
|
||||||
|
const unplayedCount = () => {
|
||||||
|
// This would be calculated based on episode status
|
||||||
|
return props.feed.episodes.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityIcon = () => {
|
||||||
|
return props.feed.visibility === "public" ? "[P]" : "[*]"
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityColor = () => {
|
||||||
|
return props.feed.visibility === "public" ? "green" : "yellow"
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinnedIndicator = () => {
|
||||||
|
return props.feed.isPinned ? "*" : " "
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.compact) {
|
||||||
|
// Compact single-line view
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
backgroundColor={props.isSelected ? "#333" : undefined}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
>
|
||||||
|
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||||
|
{props.isSelected ? ">" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||||
|
<text fg={props.isSelected ? "white" : undefined}>
|
||||||
|
{props.feed.customName || props.feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
{props.showEpisodeCount && (
|
||||||
|
<text fg="gray">({episodeCount()})</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full view with details
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
border={props.isSelected}
|
||||||
|
borderColor={props.isSelected ? "cyan" : undefined}
|
||||||
|
backgroundColor={props.isSelected ? "#222" : undefined}
|
||||||
|
padding={1}
|
||||||
|
>
|
||||||
|
{/* Title row */}
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||||
|
{props.isSelected ? ">" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||||
|
<text fg="yellow">{pinnedIndicator()}</text>
|
||||||
|
<text fg={props.isSelected ? "white" : undefined}>
|
||||||
|
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Details row */}
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={4}>
|
||||||
|
{props.showEpisodeCount && (
|
||||||
|
<text fg="gray">
|
||||||
|
{episodeCount()} episodes ({unplayedCount()} new)
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{props.showLastUpdated && (
|
||||||
|
<text fg="gray">Updated: {formatDate(props.feed.lastUpdated)}</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Description (truncated) */}
|
||||||
|
{props.feed.podcast.description && (
|
||||||
|
<box paddingLeft={4} paddingTop={0}>
|
||||||
|
<text fg="gray">
|
||||||
|
{props.feed.podcast.description.slice(0, 60)}
|
||||||
|
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
src/components/FeedList.tsx
Normal file
182
src/components/FeedList.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Feed list component for PodTUI
|
||||||
|
* Scrollable list of feeds with keyboard navigation and mouse support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For, Show } from "solid-js"
|
||||||
|
import { FeedItem } from "./FeedItem"
|
||||||
|
import { useFeedStore } from "../stores/feed"
|
||||||
|
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
|
||||||
|
interface FeedListProps {
|
||||||
|
focused?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
showEpisodeCount?: boolean
|
||||||
|
showLastUpdated?: boolean
|
||||||
|
onSelectFeed?: (feed: Feed) => void
|
||||||
|
onOpenFeed?: (feed: Feed) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedList(props: FeedListProps) {
|
||||||
|
const feedStore = useFeedStore()
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
|
|
||||||
|
const filteredFeeds = () => feedStore.getFilteredFeeds()
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string }) => {
|
||||||
|
const feeds = filteredFeeds()
|
||||||
|
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||||
|
} else if (key.name === "down" || key.name === "j") {
|
||||||
|
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1))
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
const feed = feeds[selectedIndex()]
|
||||||
|
if (feed && props.onOpenFeed) {
|
||||||
|
props.onOpenFeed(feed)
|
||||||
|
}
|
||||||
|
} else if (key.name === "home" || key.name === "g") {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
} else if (key.name === "end") {
|
||||||
|
setSelectedIndex(feeds.length - 1)
|
||||||
|
} else if (key.name === "pageup") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 5))
|
||||||
|
} else if (key.name === "pagedown") {
|
||||||
|
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5))
|
||||||
|
} else if (key.name === "p") {
|
||||||
|
// Toggle pin on selected feed
|
||||||
|
const feed = feeds[selectedIndex()]
|
||||||
|
if (feed) {
|
||||||
|
feedStore.togglePinned(feed.id)
|
||||||
|
}
|
||||||
|
} else if (key.name === "f") {
|
||||||
|
// Cycle visibility filter
|
||||||
|
cycleVisibilityFilter()
|
||||||
|
} else if (key.name === "s") {
|
||||||
|
// Cycle sort
|
||||||
|
cycleSortField()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify selection change
|
||||||
|
const selectedFeed = feeds[selectedIndex()]
|
||||||
|
if (selectedFeed && props.onSelectFeed) {
|
||||||
|
props.onSelectFeed(selectedFeed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleVisibilityFilter = () => {
|
||||||
|
const current = feedStore.filter().visibility
|
||||||
|
let next: FeedVisibility | "all"
|
||||||
|
if (current === "all") next = FeedVisibility.PUBLIC
|
||||||
|
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
|
||||||
|
else next = "all"
|
||||||
|
feedStore.setFilter({ ...feedStore.filter(), visibility: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleSortField = () => {
|
||||||
|
const sortOptions: FeedSortField[] = [
|
||||||
|
FeedSortField.UPDATED,
|
||||||
|
FeedSortField.TITLE,
|
||||||
|
FeedSortField.EPISODE_COUNT,
|
||||||
|
FeedSortField.LATEST_EPISODE,
|
||||||
|
]
|
||||||
|
const current = feedStore.filter().sortBy as FeedSortField
|
||||||
|
const idx = sortOptions.indexOf(current)
|
||||||
|
const next = sortOptions[(idx + 1) % sortOptions.length]
|
||||||
|
feedStore.setFilter({ ...feedStore.filter(), sortBy: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityLabel = () => {
|
||||||
|
const vis = feedStore.filter().visibility
|
||||||
|
if (vis === "all") return "All"
|
||||||
|
if (vis === "public") return "Public"
|
||||||
|
return "Private"
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortLabel = () => {
|
||||||
|
const sort = feedStore.filter().sortBy
|
||||||
|
switch (sort) {
|
||||||
|
case "title": return "Title"
|
||||||
|
case "episodeCount": return "Episodes"
|
||||||
|
case "latestEpisode": return "Latest"
|
||||||
|
default: return "Updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFeedClick = (feed: Feed, index: number) => {
|
||||||
|
setSelectedIndex(index)
|
||||||
|
if (props.onSelectFeed) {
|
||||||
|
props.onSelectFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFeedDoubleClick = (feed: Feed) => {
|
||||||
|
if (props.onOpenFeed) {
|
||||||
|
props.onOpenFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
{/* Header with filter controls */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||||
|
<text>
|
||||||
|
<strong>My Feeds</strong>
|
||||||
|
</text>
|
||||||
|
<text fg="gray">({filteredFeeds().length} feeds)</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={cycleVisibilityFilter}
|
||||||
|
>
|
||||||
|
<text fg="cyan">[f] {visibilityLabel()}</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={cycleSortField}
|
||||||
|
>
|
||||||
|
<text fg="cyan">[s] {sortLabel()}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Feed list in scrollbox */}
|
||||||
|
<Show
|
||||||
|
when={filteredFeeds().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box border padding={2}>
|
||||||
|
<text fg="gray">
|
||||||
|
No feeds found. Add podcasts from the Discover or Search tabs.
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox height={15} focused={props.focused}>
|
||||||
|
<For each={filteredFeeds()}>
|
||||||
|
{(feed, index) => (
|
||||||
|
<box onMouseDown={() => handleFeedClick(feed, index())}>
|
||||||
|
<FeedItem
|
||||||
|
feed={feed}
|
||||||
|
isSelected={index() === selectedIndex()}
|
||||||
|
compact={props.compact}
|
||||||
|
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||||
|
showLastUpdated={props.showLastUpdated ?? true}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Navigation help */}
|
||||||
|
<box paddingTop={0}>
|
||||||
|
<text fg="gray">
|
||||||
|
j/k navigate | Enter open | p pin | f filter | s sort | Click to select
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/components/FileInfo.tsx
Normal file
17
src/components/FileInfo.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
type FileInfoProps = {
|
||||||
|
path: string
|
||||||
|
format: string
|
||||||
|
size: string
|
||||||
|
modifiedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileInfo(props: FileInfoProps) {
|
||||||
|
return (
|
||||||
|
<box border title="File Info" style={{ padding: 1, flexDirection: "column" }}>
|
||||||
|
<text>Path: {props.path}</text>
|
||||||
|
<text>Format: {props.format}</text>
|
||||||
|
<text>Size: {props.size}</text>
|
||||||
|
<text>Modified: {props.modifiedAt}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/components/FilePicker.tsx
Normal file
22
src/components/FilePicker.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { detectFormat } from "../utils/file-detector"
|
||||||
|
|
||||||
|
type FilePickerProps = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilePicker(props: FilePickerProps) {
|
||||||
|
const format = detectFormat(props.value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||||
|
<input
|
||||||
|
value={props.value}
|
||||||
|
onInput={props.onChange}
|
||||||
|
placeholder="/path/to/sync-file.json"
|
||||||
|
style={{ width: 40 }}
|
||||||
|
/>
|
||||||
|
<text>Format: {format}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/components/ImportDialog.tsx
Normal file
21
src/components/ImportDialog.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
|
let current = value
|
||||||
|
return [() => current, (next) => {
|
||||||
|
current = next
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
import { FilePicker } from "./FilePicker"
|
||||||
|
|
||||||
|
export function ImportDialog() {
|
||||||
|
const filePath = createSignal("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box border title="Import" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
|
<FilePicker value={filePath[0]()} onChange={filePath[1]} />
|
||||||
|
<box border>
|
||||||
|
<text>Import selected file</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/components/KeyboardHandler.tsx
Normal file
17
src/components/KeyboardHandler.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import type { TabId } from "./Tab"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use useAppKeyboard hook directly instead.
|
||||||
|
* This component is kept for backwards compatibility.
|
||||||
|
*/
|
||||||
|
type KeyboardHandlerProps = {
|
||||||
|
children?: JSX.Element
|
||||||
|
onTabSelect?: (tab: TabId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardHandler(props: KeyboardHandlerProps) {
|
||||||
|
// Keyboard handling has been moved to useAppKeyboard hook
|
||||||
|
// This component is now just a passthrough
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
17
src/components/Layout.tsx
Normal file
17
src/components/Layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
|
||||||
|
type LayoutProps = {
|
||||||
|
header?: JSX.Element
|
||||||
|
footer?: JSX.Element
|
||||||
|
children?: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout(props: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<box style={{ flexDirection: "column", width: "100%", height: "100%" }}>
|
||||||
|
{props.header ? <box style={{ height: 3 }}>{props.header}</box> : <text></text>}
|
||||||
|
<box style={{ flexGrow: 1 }}>{props.children}</box>
|
||||||
|
{props.footer ? <box style={{ height: 1 }}>{props.footer}</box> : <text></text>}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
175
src/components/LoginScreen.tsx
Normal file
175
src/components/LoginScreen.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Login screen component for PodTUI
|
||||||
|
* Email/password login with links to code validation and OAuth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useAuthStore } from "../stores/auth"
|
||||||
|
import { AUTH_CONFIG } from "../config/auth"
|
||||||
|
|
||||||
|
interface LoginScreenProps {
|
||||||
|
focused?: boolean
|
||||||
|
onNavigateToCode?: () => void
|
||||||
|
onNavigateToOAuth?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusField = "email" | "password" | "submit" | "code" | "oauth"
|
||||||
|
|
||||||
|
export function LoginScreen(props: LoginScreenProps) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const [email, setEmail] = createSignal("")
|
||||||
|
const [password, setPassword] = createSignal("")
|
||||||
|
const [focusField, setFocusField] = createSignal<FocusField>("email")
|
||||||
|
const [emailError, setEmailError] = createSignal<string | null>(null)
|
||||||
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"]
|
||||||
|
|
||||||
|
const validateEmail = (value: string): boolean => {
|
||||||
|
if (!value) {
|
||||||
|
setEmailError("Email is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!AUTH_CONFIG.email.pattern.test(value)) {
|
||||||
|
setEmailError("Invalid email format")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setEmailError(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatePassword = (value: string): boolean => {
|
||||||
|
if (!value) {
|
||||||
|
setPasswordError("Password is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (value.length < AUTH_CONFIG.password.minLength) {
|
||||||
|
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setPasswordError(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const isEmailValid = validateEmail(email())
|
||||||
|
const isPasswordValid = validatePassword(password())
|
||||||
|
|
||||||
|
if (!isEmailValid || !isPasswordValid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await auth.login({ email: email(), password: password() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const currentIndex = fields.indexOf(focusField())
|
||||||
|
const nextIndex = key.shift
|
||||||
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
|
: (currentIndex + 1) % fields.length
|
||||||
|
setFocusField(fields[nextIndex])
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
if (focusField() === "submit") {
|
||||||
|
handleSubmit()
|
||||||
|
} else if (focusField() === "code" && props.onNavigateToCode) {
|
||||||
|
props.onNavigateToCode()
|
||||||
|
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
|
||||||
|
props.onNavigateToOAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" border padding={2} gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>Sign In</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Email field */}
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg={focusField() === "email" ? "cyan" : undefined}>Email:</text>
|
||||||
|
<input
|
||||||
|
value={email()}
|
||||||
|
onInput={setEmail}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
focused={props.focused && focusField() === "email"}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
{emailError() && (
|
||||||
|
<text fg="red">{emailError()}</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Password field */}
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg={focusField() === "password" ? "cyan" : undefined}>
|
||||||
|
Password:
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
value={password()}
|
||||||
|
onInput={setPassword}
|
||||||
|
placeholder="********"
|
||||||
|
focused={props.focused && focusField() === "password"}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
{passwordError() && (
|
||||||
|
<text fg="red">{passwordError()}</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "submit" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||||
|
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Auth error message */}
|
||||||
|
{auth.error && (
|
||||||
|
<text fg="red">{auth.error.message}</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Alternative auth options */}
|
||||||
|
<text fg="gray">Or authenticate with:</text>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "code" ? "yellow" : "gray"}>
|
||||||
|
[C] Sync Code
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "oauth" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "oauth" ? "yellow" : "gray"}>
|
||||||
|
[O] OAuth Info
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
<text fg="gray">Tab to navigate, Enter to select</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/Navigation.tsx
Normal file
24
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { TabId } from "./Tab"
|
||||||
|
|
||||||
|
type NavigationProps = {
|
||||||
|
activeTab: TabId
|
||||||
|
onTabSelect: (tab: TabId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navigation(props: NavigationProps) {
|
||||||
|
return (
|
||||||
|
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
||||||
|
<text>
|
||||||
|
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
|
||||||
|
<span> </span>
|
||||||
|
{props.activeTab === "feeds" ? "[" : " "}My Feeds{props.activeTab === "feeds" ? "]" : " "}
|
||||||
|
<span> </span>
|
||||||
|
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
|
||||||
|
<span> </span>
|
||||||
|
{props.activeTab === "player" ? "[" : " "}Player{props.activeTab === "player" ? "]" : " "}
|
||||||
|
<span> </span>
|
||||||
|
{props.activeTab === "settings" ? "[" : " "}Settings{props.activeTab === "settings" ? "]" : " "}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/components/OAuthPlaceholder.tsx
Normal file
125
src/components/OAuthPlaceholder.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* OAuth placeholder component for PodTUI
|
||||||
|
* Displays OAuth limitations and alternative authentication methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth"
|
||||||
|
|
||||||
|
interface OAuthPlaceholderProps {
|
||||||
|
focused?: boolean
|
||||||
|
onBack?: () => void
|
||||||
|
onNavigateToCode?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusField = "code" | "back"
|
||||||
|
|
||||||
|
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||||
|
const [focusField, setFocusField] = createSignal<FocusField>("code")
|
||||||
|
|
||||||
|
const fields: FocusField[] = ["code", "back"]
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const currentIndex = fields.indexOf(focusField())
|
||||||
|
const nextIndex = key.shift
|
||||||
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
|
: (currentIndex + 1) % fields.length
|
||||||
|
setFocusField(fields[nextIndex])
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
if (focusField() === "code" && props.onNavigateToCode) {
|
||||||
|
props.onNavigateToCode()
|
||||||
|
} else if (focusField() === "back" && props.onBack) {
|
||||||
|
props.onBack()
|
||||||
|
}
|
||||||
|
} else if (key.name === "escape" && props.onBack) {
|
||||||
|
props.onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" border padding={2} gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>OAuth Authentication</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* OAuth providers list */}
|
||||||
|
<text fg="cyan">Available OAuth Providers:</text>
|
||||||
|
|
||||||
|
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||||
|
{OAUTH_PROVIDERS.map((provider) => (
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={provider.enabled ? "green" : "gray"}>
|
||||||
|
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
||||||
|
</text>
|
||||||
|
<text fg="gray">- {provider.description}</text>
|
||||||
|
</box>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Limitation message */}
|
||||||
|
<box border padding={1} borderColor="yellow">
|
||||||
|
<text fg="yellow">Terminal Limitations</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
||||||
|
<text fg="gray">{line}</text>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Alternative options */}
|
||||||
|
<text fg="cyan">Recommended Alternatives:</text>
|
||||||
|
|
||||||
|
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="green">[1]</text>
|
||||||
|
<text fg="white">Use a sync code from the web portal</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="green">[2]</text>
|
||||||
|
<text fg="white">Use email/password authentication</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="green">[3]</text>
|
||||||
|
<text fg="white">Use file-based sync (no account needed)</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "code" ? "cyan" : undefined}>
|
||||||
|
[C] Enter Sync Code
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||||
|
[Esc] Back to Login
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
src/components/PodcastCard.tsx
Normal file
73
src/components/PodcastCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* PodcastCard component - Reusable card for displaying podcast info
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
|
||||||
|
type PodcastCardProps = {
|
||||||
|
podcast: Podcast
|
||||||
|
selected: boolean
|
||||||
|
compact?: boolean
|
||||||
|
onSelect?: () => void
|
||||||
|
onSubscribe?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PodcastCard(props: PodcastCardProps) {
|
||||||
|
const handleSubscribeClick = () => {
|
||||||
|
props.onSubscribe?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={props.selected ? "#333" : undefined}
|
||||||
|
onMouseDown={props.onSelect}
|
||||||
|
>
|
||||||
|
{/* Title Row */}
|
||||||
|
<box flexDirection="row" gap={2} alignItems="center">
|
||||||
|
<text fg={props.selected ? "cyan" : "white"}>
|
||||||
|
<strong>{props.podcast.title}</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<Show when={props.podcast.isSubscribed}>
|
||||||
|
<text fg="green">[+]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Author */}
|
||||||
|
<Show when={props.podcast.author && !props.compact}>
|
||||||
|
<text fg="gray">by {props.podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Show when={props.podcast.description && !props.compact}>
|
||||||
|
<text fg={props.selected ? "white" : "gray"}>
|
||||||
|
{props.podcast.description!.length > 80
|
||||||
|
? props.podcast.description!.slice(0, 80) + "..."
|
||||||
|
: props.podcast.description}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Categories and Subscribe Button */}
|
||||||
|
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Show when={(props.podcast.categories ?? []).length > 0}>
|
||||||
|
{(props.podcast.categories ?? []).slice(0, 2).map((cat) => (
|
||||||
|
<text fg="yellow">[{cat}]</text>
|
||||||
|
))}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Show when={props.selected}>
|
||||||
|
<box onMouseDown={handleSubscribeClick}>
|
||||||
|
<text fg={props.podcast.isSubscribed ? "red" : "green"}>
|
||||||
|
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/components/ResponsiveContainer.tsx
Normal file
19
src/components/ResponsiveContainer.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createMemo, type JSX } from "solid-js"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
|
||||||
|
type ResponsiveContainerProps = {
|
||||||
|
children?: (size: "small" | "medium" | "large") => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveContainer(props: ResponsiveContainerProps) {
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
|
||||||
|
const size = createMemo<"small" | "medium" | "large">(() => {
|
||||||
|
const width = dimensions().width
|
||||||
|
if (width < 60) return "small"
|
||||||
|
if (width < 100) return "medium"
|
||||||
|
return "large"
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>{props.children?.(size())}</>
|
||||||
|
}
|
||||||
79
src/components/ResultCard.tsx
Normal file
79
src/components/ResultCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import type { SearchResult } from "../types/source"
|
||||||
|
import { SourceBadge } from "./SourceBadge"
|
||||||
|
|
||||||
|
type ResultCardProps = {
|
||||||
|
result: SearchResult
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onSubscribe?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultCard(props: ResultCardProps) {
|
||||||
|
const podcast = () => props.result.podcast
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
border={props.selected}
|
||||||
|
borderColor={props.selected ? "cyan" : undefined}
|
||||||
|
backgroundColor={props.selected ? "#222" : undefined}
|
||||||
|
onMouseDown={props.onSelect}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<box flexDirection="row" gap={2} alignItems="center">
|
||||||
|
<text fg={props.selected ? "cyan" : "white"}>
|
||||||
|
<strong>{podcast().title}</strong>
|
||||||
|
</text>
|
||||||
|
<SourceBadge
|
||||||
|
sourceId={props.result.sourceId}
|
||||||
|
sourceName={props.result.sourceName}
|
||||||
|
sourceType={props.result.sourceType}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<Show when={podcast().isSubscribed}>
|
||||||
|
<text fg="green">[Subscribed]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Show when={podcast().author}>
|
||||||
|
<text fg="gray">by {podcast().author}</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={podcast().description}>
|
||||||
|
{(description) => (
|
||||||
|
<text fg={props.selected ? "white" : "gray"}>
|
||||||
|
{description().length > 120
|
||||||
|
? description().slice(0, 120) + "..."
|
||||||
|
: description()}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={(podcast().categories ?? []).length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
{(podcast().categories ?? []).slice(0, 3).map((category) => (
|
||||||
|
<text fg="yellow">[{category}]</text>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!podcast().isSubscribed}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
width={18}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.stopPropagation?.()
|
||||||
|
props.onSubscribe?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg="cyan">[+] Add to Feeds</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/components/ResultDetail.tsx
Normal file
75
src/components/ResultDetail.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import type { SearchResult } from "../types/source"
|
||||||
|
import { SourceBadge } from "./SourceBadge"
|
||||||
|
|
||||||
|
type ResultDetailProps = {
|
||||||
|
result?: SearchResult
|
||||||
|
onSubscribe?: (result: SearchResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultDetail(props: ResultDetailProps) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" border padding={1} gap={1} height="100%">
|
||||||
|
<Show
|
||||||
|
when={props.result}
|
||||||
|
fallback={
|
||||||
|
<text fg="gray">Select a result to see details.</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(result) => (
|
||||||
|
<>
|
||||||
|
<text fg="white">
|
||||||
|
<strong>{result().podcast.title}</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<SourceBadge
|
||||||
|
sourceId={result().sourceId}
|
||||||
|
sourceName={result().sourceName}
|
||||||
|
sourceType={result().sourceType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Show when={result().podcast.author}>
|
||||||
|
<text fg="gray">by {result().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={result().podcast.description}>
|
||||||
|
<text fg="gray">{result().podcast.description}</text>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
{(result().podcast.categories ?? []).map((category) => (
|
||||||
|
<text fg="yellow">[{category}]</text>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
|
||||||
|
|
||||||
|
<text fg="gray">
|
||||||
|
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<Show when={!result().podcast.isSubscribed}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
width={18}
|
||||||
|
onMouseDown={() => props.onSubscribe?.(result())}
|
||||||
|
>
|
||||||
|
<text fg="cyan">[+] Add to Feeds</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={result().podcast.isSubscribed}>
|
||||||
|
<text fg="green">Already subscribed</text>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/components/Row.tsx
Normal file
35
src/components/Row.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
|
||||||
|
type RowProps = {
|
||||||
|
children?: JSX.Element
|
||||||
|
gap?: number
|
||||||
|
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||||
|
justifyContent?:
|
||||||
|
| "flex-start"
|
||||||
|
| "flex-end"
|
||||||
|
| "center"
|
||||||
|
| "space-between"
|
||||||
|
| "space-around"
|
||||||
|
| "space-evenly"
|
||||||
|
width?: number | "auto" | `${number}%`
|
||||||
|
height?: number | "auto" | `${number}%`
|
||||||
|
padding?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Row(props: RowProps) {
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: props.gap,
|
||||||
|
alignItems: props.alignItems,
|
||||||
|
justifyContent: props.justifyContent,
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
padding: props.padding,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/components/SearchHistory.tsx
Normal file
78
src/components/SearchHistory.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* SearchHistory component for displaying and managing search history
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
|
||||||
|
type SearchHistoryProps = {
|
||||||
|
history: string[]
|
||||||
|
focused: boolean
|
||||||
|
selectedIndex: number
|
||||||
|
onSelect?: (query: string) => void
|
||||||
|
onRemove?: (query: string) => void
|
||||||
|
onClear?: () => void
|
||||||
|
onChange?: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchHistory(props: SearchHistoryProps) {
|
||||||
|
const handleSearchClick = (index: number, query: string) => {
|
||||||
|
props.onChange?.(index)
|
||||||
|
props.onSelect?.(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveClick = (query: string) => {
|
||||||
|
props.onRemove?.(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text fg="gray">Recent Searches</text>
|
||||||
|
<Show when={props.history.length > 0}>
|
||||||
|
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
||||||
|
<text fg="red">[Clear All]</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={props.history.length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg="gray">No recent searches</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox height={10}>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={props.history}>
|
||||||
|
{(query, index) => {
|
||||||
|
const isSelected = () => index() === props.selectedIndex && props.focused
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={isSelected() ? "#333" : undefined}
|
||||||
|
onMouseDown={() => handleSearchClick(index(), query)}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">{">"}</text>
|
||||||
|
<text fg={isSelected() ? "cyan" : "white"}>{query}</text>
|
||||||
|
</box>
|
||||||
|
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
||||||
|
<text fg="red">[x]</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
268
src/components/SearchPage.tsx
Normal file
268
src/components/SearchPage.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* SearchPage component - Main search interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, Show } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { useSearchStore } from "../stores/search"
|
||||||
|
import { SearchResults } from "./SearchResults"
|
||||||
|
import { SearchHistory } from "./SearchHistory"
|
||||||
|
import type { SearchResult } from "../types/source"
|
||||||
|
|
||||||
|
type SearchPageProps = {
|
||||||
|
focused: boolean
|
||||||
|
onSubscribe?: (result: SearchResult) => void
|
||||||
|
onInputFocusChange?: (focused: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusArea = "input" | "results" | "history"
|
||||||
|
|
||||||
|
export function SearchPage(props: SearchPageProps) {
|
||||||
|
const searchStore = useSearchStore()
|
||||||
|
const [focusArea, setFocusArea] = createSignal<FocusArea>("input")
|
||||||
|
const [inputValue, setInputValue] = createSignal("")
|
||||||
|
const [resultIndex, setResultIndex] = createSignal(0)
|
||||||
|
const [historyIndex, setHistoryIndex] = createSignal(0)
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const query = inputValue().trim()
|
||||||
|
if (query) {
|
||||||
|
await searchStore.search(query)
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
setResultIndex(0)
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHistorySelect = async (query: string) => {
|
||||||
|
setInputValue(query)
|
||||||
|
await searchStore.search(query)
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
setResultIndex(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResultSelect = (result: SearchResult) => {
|
||||||
|
props.onSubscribe?.(result)
|
||||||
|
searchStore.markSubscribed(result.podcast.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useKeyboard((key) => {
|
||||||
|
if (!props.focused) return
|
||||||
|
|
||||||
|
const area = focusArea()
|
||||||
|
|
||||||
|
// Enter to search from input
|
||||||
|
if (key.name === "enter" && area === "input") {
|
||||||
|
handleSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab to cycle focus areas
|
||||||
|
if (key.name === "tab" && !key.shift) {
|
||||||
|
if (area === "input") {
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
} else if (searchStore.history().length > 0) {
|
||||||
|
setFocusArea("history")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
}
|
||||||
|
} else if (area === "results") {
|
||||||
|
if (searchStore.history().length > 0) {
|
||||||
|
setFocusArea("history")
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "tab" && key.shift) {
|
||||||
|
if (area === "input") {
|
||||||
|
if (searchStore.history().length > 0) {
|
||||||
|
setFocusArea("history")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
} else if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
props.onInputFocusChange?.(false)
|
||||||
|
}
|
||||||
|
} else if (area === "history") {
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
setFocusArea("results")
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up/Down for results and history
|
||||||
|
if (area === "results") {
|
||||||
|
const results = searchStore.results()
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setResultIndex((i) => Math.min(i + 1, results.length - 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setResultIndex((i) => Math.max(i - 1, 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
const result = results[resultIndex()]
|
||||||
|
if (result) handleResultSelect(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area === "history") {
|
||||||
|
const history = searchStore.history()
|
||||||
|
if (key.name === "down" || key.name === "j") {
|
||||||
|
setHistoryIndex((i) => Math.min(i + 1, history.length - 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setHistoryIndex((i) => Math.max(i - 1, 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "enter") {
|
||||||
|
const query = history[historyIndex()]
|
||||||
|
if (query) handleHistorySelect(query)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape goes back to input
|
||||||
|
if (key.name === "escape") {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "/" focuses search input
|
||||||
|
if (key.name === "/" && area !== "input") {
|
||||||
|
setFocusArea("input")
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%" gap={1}>
|
||||||
|
{/* Search Header */}
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>Search Podcasts</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg="gray">Search:</text>
|
||||||
|
<input
|
||||||
|
value={inputValue()}
|
||||||
|
onInput={(value) => {
|
||||||
|
setInputValue(value)
|
||||||
|
if (props.focused && focusArea() === "input") {
|
||||||
|
props.onInputFocusChange?.(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter podcast name, topic, or author..."
|
||||||
|
focused={props.focused && focusArea() === "input"}
|
||||||
|
width={50}
|
||||||
|
/>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
onMouseDown={handleSearch}
|
||||||
|
>
|
||||||
|
<text fg="cyan">[Enter] Search</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Show when={searchStore.isSearching()}>
|
||||||
|
<text fg="yellow">Searching...</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={searchStore.error()}>
|
||||||
|
<text fg="red">{searchStore.error()}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Main Content - Results or History */}
|
||||||
|
<box flexDirection="row" height="100%" gap={2}>
|
||||||
|
{/* Results Panel */}
|
||||||
|
<box flexDirection="column" flexGrow={1} border>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={focusArea() === "results" ? "cyan" : "gray"}>
|
||||||
|
Results ({searchStore.results().length})
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<Show
|
||||||
|
when={searchStore.results().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={2}>
|
||||||
|
<text fg="gray">
|
||||||
|
{searchStore.query()
|
||||||
|
? "No results found"
|
||||||
|
: "Enter a search term to find podcasts"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchResults
|
||||||
|
results={searchStore.results()}
|
||||||
|
selectedIndex={resultIndex()}
|
||||||
|
focused={focusArea() === "results"}
|
||||||
|
onSelect={handleResultSelect}
|
||||||
|
onChange={setResultIndex}
|
||||||
|
isSearching={searchStore.isSearching()}
|
||||||
|
error={searchStore.error()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* History Sidebar */}
|
||||||
|
<box width={30} border>
|
||||||
|
<box padding={1} flexDirection="column">
|
||||||
|
<box paddingBottom={1}>
|
||||||
|
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
|
||||||
|
History
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<SearchHistory
|
||||||
|
history={searchStore.history()}
|
||||||
|
selectedIndex={historyIndex()}
|
||||||
|
focused={focusArea() === "history"}
|
||||||
|
onSelect={handleHistorySelect}
|
||||||
|
onRemove={searchStore.removeFromHistory}
|
||||||
|
onClear={searchStore.clearHistory}
|
||||||
|
onChange={setHistoryIndex}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Footer Hints */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg="gray">[Tab] Switch focus</text>
|
||||||
|
<text fg="gray">[/] Focus search</text>
|
||||||
|
<text fg="gray">[Enter] Select</text>
|
||||||
|
<text fg="gray">[Esc] Back to search</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/components/SearchResults.tsx
Normal file
75
src/components/SearchResults.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* SearchResults component for displaying podcast search results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import type { SearchResult } from "../types/source"
|
||||||
|
import { ResultCard } from "./ResultCard"
|
||||||
|
import { ResultDetail } from "./ResultDetail"
|
||||||
|
|
||||||
|
type SearchResultsProps = {
|
||||||
|
results: SearchResult[]
|
||||||
|
selectedIndex: number
|
||||||
|
focused: boolean
|
||||||
|
onSelect?: (result: SearchResult) => void
|
||||||
|
onChange?: (index: number) => void
|
||||||
|
isSearching?: boolean
|
||||||
|
error?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResults(props: SearchResultsProps) {
|
||||||
|
const handleSelect = (index: number) => {
|
||||||
|
props.onChange?.(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={!props.isSearching} fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg="yellow">Searching...</text>
|
||||||
|
</box>
|
||||||
|
}>
|
||||||
|
<Show
|
||||||
|
when={!props.error}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg="red">{props.error}</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.results.length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg="gray">No results found. Try a different search term.</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1} height="100%">
|
||||||
|
<box flexDirection="column" flexGrow={1}>
|
||||||
|
<scrollbox height="100%">
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<For each={props.results}>
|
||||||
|
{(result, index) => (
|
||||||
|
<ResultCard
|
||||||
|
result={result}
|
||||||
|
selected={index() === props.selectedIndex}
|
||||||
|
onSelect={() => handleSelect(index())}
|
||||||
|
onSubscribe={() => props.onSelect?.(result)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</box>
|
||||||
|
<box width={36}>
|
||||||
|
<ResultDetail
|
||||||
|
result={props.results[props.selectedIndex]}
|
||||||
|
onSubscribe={(result) => props.onSelect?.(result)}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/components/ShortcutHelp.tsx
Normal file
26
src/components/ShortcutHelp.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { shortcuts } from "../config/shortcuts"
|
||||||
|
|
||||||
|
export function ShortcutHelp() {
|
||||||
|
return (
|
||||||
|
<box border title="Shortcuts" style={{ padding: 1 }}>
|
||||||
|
<box style={{ flexDirection: "column" }}>
|
||||||
|
<box style={{ flexDirection: "row" }}>
|
||||||
|
<text>{shortcuts[0]?.keys ?? ""} </text>
|
||||||
|
<text>{shortcuts[0]?.action ?? ""}</text>
|
||||||
|
</box>
|
||||||
|
<box style={{ flexDirection: "row" }}>
|
||||||
|
<text>{shortcuts[1]?.keys ?? ""} </text>
|
||||||
|
<text>{shortcuts[1]?.action ?? ""}</text>
|
||||||
|
</box>
|
||||||
|
<box style={{ flexDirection: "row" }}>
|
||||||
|
<text>{shortcuts[2]?.keys ?? ""} </text>
|
||||||
|
<text>{shortcuts[2]?.action ?? ""}</text>
|
||||||
|
</box>
|
||||||
|
<box style={{ flexDirection: "row" }}>
|
||||||
|
<text>{shortcuts[3]?.keys ?? ""} </text>
|
||||||
|
<text>{shortcuts[3]?.action ?? ""}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/SourceBadge.tsx
Normal file
34
src/components/SourceBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { SourceType } from "../types/source"
|
||||||
|
|
||||||
|
type SourceBadgeProps = {
|
||||||
|
sourceId: string
|
||||||
|
sourceName?: string
|
||||||
|
sourceType?: SourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = (sourceType?: SourceType) => {
|
||||||
|
if (sourceType === SourceType.API) return "API"
|
||||||
|
if (sourceType === SourceType.RSS) return "RSS"
|
||||||
|
if (sourceType === SourceType.CUSTOM) return "Custom"
|
||||||
|
return "Source"
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColor = (sourceType?: SourceType) => {
|
||||||
|
if (sourceType === SourceType.API) return "cyan"
|
||||||
|
if (sourceType === SourceType.RSS) return "green"
|
||||||
|
if (sourceType === SourceType.CUSTOM) return "yellow"
|
||||||
|
return "gray"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceBadge(props: SourceBadgeProps) {
|
||||||
|
const label = () => props.sourceName || props.sourceId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={1} padding={0}>
|
||||||
|
<text fg={typeColor(props.sourceType)}>
|
||||||
|
[{typeLabel(props.sourceType)}]
|
||||||
|
</text>
|
||||||
|
<text fg="gray">{label()}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
300
src/components/SourceManager.tsx
Normal file
300
src/components/SourceManager.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Source management component for PodTUI
|
||||||
|
* Add, remove, and configure podcast sources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For } from "solid-js"
|
||||||
|
import { useFeedStore } from "../stores/feed"
|
||||||
|
import { SourceType } from "../types/source"
|
||||||
|
import type { PodcastSource } from "../types/source"
|
||||||
|
|
||||||
|
interface SourceManagerProps {
|
||||||
|
focused?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"
|
||||||
|
|
||||||
|
export function SourceManager(props: SourceManagerProps) {
|
||||||
|
const feedStore = useFeedStore()
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
|
const [focusArea, setFocusArea] = createSignal<FocusArea>("list")
|
||||||
|
const [newSourceUrl, setNewSourceUrl] = createSignal("")
|
||||||
|
const [newSourceName, setNewSourceName] = createSignal("")
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const sources = () => feedStore.sources()
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "escape") {
|
||||||
|
if (focusArea() !== "list") {
|
||||||
|
setFocusArea("list")
|
||||||
|
setError(null)
|
||||||
|
} else if (props.onClose) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const areas: FocusArea[] = [
|
||||||
|
"list",
|
||||||
|
"country",
|
||||||
|
"language",
|
||||||
|
"explicit",
|
||||||
|
"add",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
const idx = areas.indexOf(focusArea())
|
||||||
|
const nextIdx = key.shift
|
||||||
|
? (idx - 1 + areas.length) % areas.length
|
||||||
|
: (idx + 1) % areas.length
|
||||||
|
setFocusArea(areas[nextIdx])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusArea() === "list") {
|
||||||
|
if (key.name === "up" || key.name === "k") {
|
||||||
|
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||||
|
} else if (key.name === "down" || key.name === "j") {
|
||||||
|
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1))
|
||||||
|
} else if (key.name === "return" || key.name === "enter" || key.name === "space") {
|
||||||
|
const source = sources()[selectedIndex()]
|
||||||
|
if (source) {
|
||||||
|
feedStore.toggleSource(source.id)
|
||||||
|
}
|
||||||
|
} else if (key.name === "d" || key.name === "delete") {
|
||||||
|
const source = sources()[selectedIndex()]
|
||||||
|
if (source) {
|
||||||
|
const removed = feedStore.removeSource(source.id)
|
||||||
|
if (!removed) {
|
||||||
|
setError("Cannot remove default sources")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key.name === "a") {
|
||||||
|
setFocusArea("add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusArea() === "country") {
|
||||||
|
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||||
|
const source = sources()[selectedIndex()]
|
||||||
|
if (source && source.type === SourceType.API) {
|
||||||
|
const next = source.country === "US" ? "GB" : "US"
|
||||||
|
feedStore.updateSource(source.id, { country: next })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusArea() === "explicit") {
|
||||||
|
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||||
|
const source = sources()[selectedIndex()]
|
||||||
|
if (source && source.type === SourceType.API) {
|
||||||
|
feedStore.updateSource(source.id, { allowExplicit: !source.allowExplicit })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusArea() === "language") {
|
||||||
|
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||||
|
const source = sources()[selectedIndex()]
|
||||||
|
if (source && source.type === SourceType.API) {
|
||||||
|
const next = source.language === "ja_jp" ? "en_us" : "ja_jp"
|
||||||
|
feedStore.updateSource(source.id, { language: next })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSource = () => {
|
||||||
|
const url = newSourceUrl().trim()
|
||||||
|
const name = newSourceName().trim() || `Custom Source`
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
setError("URL is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
setError("Invalid URL format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feedStore.addSource({
|
||||||
|
name,
|
||||||
|
type: "rss" as SourceType,
|
||||||
|
baseUrl: url,
|
||||||
|
enabled: true,
|
||||||
|
description: `Custom RSS feed: ${url}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
setNewSourceUrl("")
|
||||||
|
setNewSourceName("")
|
||||||
|
setFocusArea("list")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSourceIcon = (source: PodcastSource) => {
|
||||||
|
if (source.type === SourceType.API) return "[API]"
|
||||||
|
if (source.type === SourceType.RSS) return "[RSS]"
|
||||||
|
return "[?]"
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSource = () => sources()[selectedIndex()]
|
||||||
|
const isApiSource = () => selectedSource()?.type === SourceType.API
|
||||||
|
const sourceCountry = () => selectedSource()?.country || "US"
|
||||||
|
const sourceExplicit = () => selectedSource()?.allowExplicit !== false
|
||||||
|
const sourceLanguage = () => selectedSource()?.language || "en_us"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" border padding={1} gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text>
|
||||||
|
<strong>Podcast Sources</strong>
|
||||||
|
</text>
|
||||||
|
<box border padding={0} onMouseDown={props.onClose}>
|
||||||
|
<text fg="cyan">[Esc] Close</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<text fg="gray">Manage where to search for podcasts</text>
|
||||||
|
|
||||||
|
{/* Source list */}
|
||||||
|
<box border padding={1} flexDirection="column" gap={1}>
|
||||||
|
<text fg={focusArea() === "list" ? "cyan" : "gray"}>Sources:</text>
|
||||||
|
<scrollbox height={6}>
|
||||||
|
<For each={sources()}>
|
||||||
|
{(source, index) => (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={
|
||||||
|
focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? "#333"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSelectedIndex(index())
|
||||||
|
setFocusArea("list")
|
||||||
|
feedStore.toggleSource(source.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={
|
||||||
|
focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? "cyan"
|
||||||
|
: "gray"
|
||||||
|
}>
|
||||||
|
{focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? ">"
|
||||||
|
: " "}
|
||||||
|
</text>
|
||||||
|
<text fg={source.enabled ? "green" : "red"}>
|
||||||
|
{source.enabled ? "[x]" : "[ ]"}
|
||||||
|
</text>
|
||||||
|
<text fg="yellow">{getSourceIcon(source)}</text>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
focusArea() === "list" && index() === selectedIndex()
|
||||||
|
? "white"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
<text fg="gray">Space/Enter to toggle, d to delete, a to add</text>
|
||||||
|
|
||||||
|
{/* API settings */}
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={isApiSource() ? "gray" : "yellow"}>
|
||||||
|
{isApiSource() ? "API Settings" : "API Settings (select an API source)"}
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={focusArea() === "country" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusArea() === "country" ? "cyan" : "gray"}>
|
||||||
|
Country: {sourceCountry()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={focusArea() === "language" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusArea() === "language" ? "cyan" : "gray"}>
|
||||||
|
Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={focusArea() === "explicit" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusArea() === "explicit" ? "cyan" : "gray"}>
|
||||||
|
Explicit: {sourceExplicit() ? "Yes" : "No"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
<text fg="gray">Enter/Space to toggle focused setting</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Add new source form */}
|
||||||
|
<box border padding={1} flexDirection="column" gap={1}>
|
||||||
|
<text fg={focusArea() === "add" || focusArea() === "url" ? "cyan" : "gray"}>
|
||||||
|
Add New Source:
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">Name:</text>
|
||||||
|
<input
|
||||||
|
value={newSourceName()}
|
||||||
|
onInput={setNewSourceName}
|
||||||
|
placeholder="My Custom Feed"
|
||||||
|
focused={props.focused && focusArea() === "add"}
|
||||||
|
width={25}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">URL:</text>
|
||||||
|
<input
|
||||||
|
value={newSourceUrl()}
|
||||||
|
onInput={(v) => {
|
||||||
|
setNewSourceUrl(v)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com/feed.rss"
|
||||||
|
focused={props.focused && focusArea() === "url"}
|
||||||
|
width={35}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
width={15}
|
||||||
|
onMouseDown={handleAddSource}
|
||||||
|
>
|
||||||
|
<text fg="green">[+] Add Source</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error() && (
|
||||||
|
<text fg="red">{error()}</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<text fg="gray">Tab to switch sections, Esc to close</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/components/SyncError.tsx
Normal file
15
src/components/SyncError.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
type SyncErrorProps = {
|
||||||
|
message: string
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncError(props: SyncErrorProps) {
|
||||||
|
return (
|
||||||
|
<box border title="Error" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
|
<text>{props.message}</text>
|
||||||
|
<box border onMouseDown={props.onRetry}>
|
||||||
|
<text>Retry</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/components/SyncPanel.tsx
Normal file
30
src/components/SyncPanel.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
|
let current = value
|
||||||
|
return [() => current, (next) => {
|
||||||
|
current = next
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
import { ImportDialog } from "./ImportDialog"
|
||||||
|
import { ExportDialog } from "./ExportDialog"
|
||||||
|
import { SyncStatus } from "./SyncStatus"
|
||||||
|
|
||||||
|
export function SyncPanel() {
|
||||||
|
const mode = createSignal<"import" | "export" | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||||
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
|
<box border onMouseDown={() => mode[1]("import")}>
|
||||||
|
<text>Import</text>
|
||||||
|
</box>
|
||||||
|
<box border onMouseDown={() => mode[1]("export")}>
|
||||||
|
<text>Export</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
<SyncStatus />
|
||||||
|
{mode[0]() === "import" ? <ImportDialog /> : null}
|
||||||
|
{mode[0]() === "export" ? <ExportDialog /> : null}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/components/SyncProfile.tsx
Normal file
148
src/components/SyncProfile.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Sync profile component for PodTUI
|
||||||
|
* Displays user profile information and sync status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { useAuthStore } from "../stores/auth"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
interface SyncProfileProps {
|
||||||
|
focused?: boolean
|
||||||
|
onLogout?: () => void
|
||||||
|
onManageSync?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusField = "sync" | "export" | "logout"
|
||||||
|
|
||||||
|
export function SyncProfile(props: SyncProfileProps) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const [focusField, setFocusField] = createSignal<FocusField>("sync")
|
||||||
|
const [lastSyncTime] = createSignal<Date | null>(new Date())
|
||||||
|
|
||||||
|
const fields: FocusField[] = ["sync", "export", "logout"]
|
||||||
|
|
||||||
|
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||||
|
if (key.name === "tab") {
|
||||||
|
const currentIndex = fields.indexOf(focusField())
|
||||||
|
const nextIndex = key.shift
|
||||||
|
? (currentIndex - 1 + fields.length) % fields.length
|
||||||
|
: (currentIndex + 1) % fields.length
|
||||||
|
setFocusField(fields[nextIndex])
|
||||||
|
} else if (key.name === "return" || key.name === "enter") {
|
||||||
|
if (focusField() === "sync" && props.onManageSync) {
|
||||||
|
props.onManageSync()
|
||||||
|
} else if (focusField() === "logout" && props.onLogout) {
|
||||||
|
handleLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
auth.logout()
|
||||||
|
if (props.onLogout) {
|
||||||
|
props.onLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null | undefined): string => {
|
||||||
|
if (!date) return "Never"
|
||||||
|
return format(date, "MMM d, yyyy HH:mm")
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = () => auth.state().user
|
||||||
|
|
||||||
|
// Get user initials for avatar
|
||||||
|
const userInitials = () => {
|
||||||
|
const name = user()?.name || "?"
|
||||||
|
return name.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" border padding={2} gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>User Profile</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* User avatar and info */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
{/* ASCII avatar */}
|
||||||
|
<box border padding={1} width={8} height={4} justifyContent="center" alignItems="center">
|
||||||
|
<text fg="cyan">{userInitials()}</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* User details */}
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg="white">{user()?.name || "Guest User"}</text>
|
||||||
|
<text fg="gray">{user()?.email || "No email"}</text>
|
||||||
|
<text fg="gray">Joined: {formatDate(user()?.createdAt)}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Sync status section */}
|
||||||
|
<box border padding={1} flexDirection="column" gap={0}>
|
||||||
|
<text fg="cyan">Sync Status</text>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">Status:</text>
|
||||||
|
<text fg={user()?.syncEnabled ? "green" : "yellow"}>
|
||||||
|
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">Last Sync:</text>
|
||||||
|
<text fg="white">{formatDate(lastSyncTime())}</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg="gray">Method:</text>
|
||||||
|
<text fg="white">File-based (JSON/XML)</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "sync" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "sync" ? "cyan" : undefined}>
|
||||||
|
[S] Manage Sync
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "export" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "export" ? "cyan" : undefined}>
|
||||||
|
[E] Export Data
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
backgroundColor={focusField() === "logout" ? "#333" : undefined}
|
||||||
|
>
|
||||||
|
<text fg={focusField() === "logout" ? "red" : "gray"}>
|
||||||
|
[L] Logout
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
|
||||||
|
<text fg="gray">Tab to navigate, Enter to select</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/components/SyncProgress.tsx
Normal file
25
src/components/SyncProgress.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
type SyncProgressProps = {
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncProgress(props: SyncProgressProps) {
|
||||||
|
const width = 30
|
||||||
|
let filled = (props.value / 100) * width
|
||||||
|
filled = filled >= 0 ? filled : 0
|
||||||
|
filled = filled <= width ? filled : width
|
||||||
|
filled = filled | 0
|
||||||
|
if (filled < 0) filled = 0
|
||||||
|
if (filled > width) filled = width
|
||||||
|
|
||||||
|
let bar = ""
|
||||||
|
for (let i = 0; i < width; i += 1) {
|
||||||
|
bar += i < filled ? "#" : "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box style={{ flexDirection: "column" }}>
|
||||||
|
<text>{bar}</text>
|
||||||
|
<text>{props.value}%</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/components/SyncStatus.tsx
Normal file
50
src/components/SyncStatus.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
|
let current = value
|
||||||
|
return [() => current, (next) => {
|
||||||
|
current = next
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
import { SyncProgress } from "./SyncProgress"
|
||||||
|
import { SyncError } from "./SyncError"
|
||||||
|
|
||||||
|
type SyncState = "idle" | "syncing" | "complete" | "error"
|
||||||
|
|
||||||
|
export function SyncStatus() {
|
||||||
|
const state = createSignal<SyncState>("idle")
|
||||||
|
const message = createSignal("Idle")
|
||||||
|
const progress = createSignal(0)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (state[0]() === "idle") {
|
||||||
|
state[1]("syncing")
|
||||||
|
message[1]("Syncing...")
|
||||||
|
progress[1](40)
|
||||||
|
} else if (state[0]() === "syncing") {
|
||||||
|
state[1]("complete")
|
||||||
|
message[1]("Sync complete")
|
||||||
|
progress[1](100)
|
||||||
|
} else if (state[0]() === "complete") {
|
||||||
|
state[1]("error")
|
||||||
|
message[1]("Sync failed")
|
||||||
|
} else {
|
||||||
|
state[1]("idle")
|
||||||
|
message[1]("Idle")
|
||||||
|
progress[1](0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box border title="Sync Status" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||||
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
|
<text>Status:</text>
|
||||||
|
<text>{message[0]()}</text>
|
||||||
|
</box>
|
||||||
|
<SyncProgress value={progress[0]()} />
|
||||||
|
{state[0]() === "error" ? <SyncError message={message[0]()} onRetry={() => toggle()} /> : null}
|
||||||
|
<box border onMouseDown={toggle}>
|
||||||
|
<text>Cycle Status</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/Tab.tsx
Normal file
36
src/components/Tab.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type TabId = "discover" | "feeds" | "search" | "player" | "settings"
|
||||||
|
|
||||||
|
export type TabDefinition = {
|
||||||
|
id: TabId
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tabs: TabDefinition[] = [
|
||||||
|
{ id: "discover", label: "Discover" },
|
||||||
|
{ id: "feeds", label: "My Feeds" },
|
||||||
|
{ id: "search", label: "Search" },
|
||||||
|
{ id: "player", label: "Player" },
|
||||||
|
{ id: "settings", label: "Settings" },
|
||||||
|
]
|
||||||
|
|
||||||
|
type TabProps = {
|
||||||
|
tab: TabDefinition
|
||||||
|
active: boolean
|
||||||
|
onSelect: (tab: TabId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tab(props: TabProps) {
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
onMouseDown={() => props.onSelect(props.tab.id)}
|
||||||
|
style={{ padding: 1, backgroundColor: props.active ? "#333333" : "transparent" }}
|
||||||
|
>
|
||||||
|
<text>
|
||||||
|
{props.active ? "[" : " "}
|
||||||
|
{props.tab.label}
|
||||||
|
{props.active ? "]" : " "}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/components/TabNavigation.tsx
Normal file
18
src/components/TabNavigation.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Tab, type TabId } from "./Tab"
|
||||||
|
|
||||||
|
type TabNavigationProps = {
|
||||||
|
activeTab: TabId
|
||||||
|
onTabSelect: (tab: TabId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabNavigation(props: TabNavigationProps) {
|
||||||
|
return (
|
||||||
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
|
<Tab tab={{ id: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} />
|
||||||
|
<Tab tab={{ id: "feeds", label: "My Feeds" }} active={props.activeTab === "feeds"} onSelect={props.onTabSelect} />
|
||||||
|
<Tab tab={{ id: "search", label: "Search" }} active={props.activeTab === "search"} onSelect={props.onTabSelect} />
|
||||||
|
<Tab tab={{ id: "player", label: "Player" }} active={props.activeTab === "player"} onSelect={props.onTabSelect} />
|
||||||
|
<Tab tab={{ id: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} />
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/components/TrendingShows.tsx
Normal file
51
src/components/TrendingShows.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* TrendingShows component - Grid/list of trending podcasts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import { PodcastCard } from "./PodcastCard"
|
||||||
|
|
||||||
|
type TrendingShowsProps = {
|
||||||
|
podcasts: Podcast[]
|
||||||
|
selectedIndex: number
|
||||||
|
focused: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
onSelect?: (index: number) => void
|
||||||
|
onSubscribe?: (podcast: Podcast) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendingShows(props: TrendingShowsProps) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%">
|
||||||
|
<Show when={props.isLoading}>
|
||||||
|
<box padding={2}>
|
||||||
|
<text fg="yellow">Loading trending shows...</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.isLoading && props.podcasts.length === 0}>
|
||||||
|
<box padding={2}>
|
||||||
|
<text fg="gray">No podcasts found in this category.</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.isLoading && props.podcasts.length > 0}>
|
||||||
|
<scrollbox height="100%">
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={props.podcasts}>
|
||||||
|
{(podcast, index) => (
|
||||||
|
<PodcastCard
|
||||||
|
podcast={podcast}
|
||||||
|
selected={index() === props.selectedIndex && props.focused}
|
||||||
|
onSelect={() => props.onSelect?.(index())}
|
||||||
|
onSubscribe={() => props.onSubscribe?.(podcast)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/config/auth.ts
Normal file
75
src/config/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Authentication configuration for PodTUI
|
||||||
|
* Authentication is DISABLED by default - users can opt-in
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OAuthProvider, type OAuthProviderConfig } from "../types/auth"
|
||||||
|
|
||||||
|
/** Default auth enabled state - DISABLED by default */
|
||||||
|
export const DEFAULT_AUTH_ENABLED = false
|
||||||
|
|
||||||
|
/** Authentication configuration */
|
||||||
|
export const AUTH_CONFIG = {
|
||||||
|
/** Whether auth is enabled by default */
|
||||||
|
defaultEnabled: DEFAULT_AUTH_ENABLED,
|
||||||
|
|
||||||
|
/** Code validation settings */
|
||||||
|
codeValidation: {
|
||||||
|
/** Code length (8 characters) */
|
||||||
|
codeLength: 8,
|
||||||
|
/** Allowed characters (alphanumeric) */
|
||||||
|
allowedChars: /^[A-Z0-9]+$/,
|
||||||
|
/** Code expiration time in minutes */
|
||||||
|
expirationMinutes: 15,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Password requirements */
|
||||||
|
password: {
|
||||||
|
minLength: 8,
|
||||||
|
requireUppercase: false,
|
||||||
|
requireLowercase: false,
|
||||||
|
requireNumber: false,
|
||||||
|
requireSpecial: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Email validation */
|
||||||
|
email: {
|
||||||
|
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Local storage keys */
|
||||||
|
storage: {
|
||||||
|
authState: "podtui_auth_state",
|
||||||
|
user: "podtui_user",
|
||||||
|
lastLogin: "podtui_last_login",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** OAuth provider configurations */
|
||||||
|
export const OAUTH_PROVIDERS: OAuthProviderConfig[] = [
|
||||||
|
{
|
||||||
|
id: OAuthProvider.GOOGLE,
|
||||||
|
name: "Google",
|
||||||
|
enabled: false, // Not feasible in terminal
|
||||||
|
description: "Sign in with Google (requires browser redirect)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OAuthProvider.APPLE,
|
||||||
|
name: "Apple",
|
||||||
|
enabled: false, // Not feasible in terminal
|
||||||
|
description: "Sign in with Apple (requires browser redirect)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Terminal OAuth limitation message */
|
||||||
|
export const OAUTH_LIMITATION_MESSAGE = `
|
||||||
|
OAuth authentication (Google, Apple) is not directly available in terminal applications.
|
||||||
|
|
||||||
|
To use OAuth:
|
||||||
|
1. Visit the web portal in your browser
|
||||||
|
2. Sign in with your preferred provider
|
||||||
|
3. Generate a sync code
|
||||||
|
4. Enter the code here to link your account
|
||||||
|
|
||||||
|
Alternatively, use email/password authentication or file-based sync.
|
||||||
|
`.trim()
|
||||||
6
src/config/shortcuts.ts
Normal file
6
src/config/shortcuts.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const shortcuts = [
|
||||||
|
{ keys: "Ctrl+Q", action: "Quit" },
|
||||||
|
{ keys: "Ctrl+S", action: "Save" },
|
||||||
|
{ keys: "Left/Right", action: "Switch tabs" },
|
||||||
|
{ keys: "Esc", action: "Close modal" },
|
||||||
|
] as const
|
||||||
12
src/constants/sync-formats.ts
Normal file
12
src/constants/sync-formats.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const syncFormats = {
|
||||||
|
json: {
|
||||||
|
version: "1.0",
|
||||||
|
extension: ".json",
|
||||||
|
},
|
||||||
|
xml: {
|
||||||
|
version: "1.0",
|
||||||
|
extension: ".xml",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]
|
||||||
99
src/hooks/useAppKeyboard.ts
Normal file
99
src/hooks/useAppKeyboard.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Centralized keyboard shortcuts hook for PodTUI
|
||||||
|
* Single handler to prevent conflicts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||||
|
import type { TabId } from "../components/Tab"
|
||||||
|
|
||||||
|
const TAB_ORDER: TabId[] = ["discover", "feeds", "search", "player", "settings"]
|
||||||
|
|
||||||
|
type ShortcutOptions = {
|
||||||
|
activeTab: TabId
|
||||||
|
onTabChange: (tab: TabId) => void
|
||||||
|
onAction?: (action: string) => void
|
||||||
|
inputFocused?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppKeyboard(options: ShortcutOptions) {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
|
||||||
|
const getNextTab = (current: TabId): TabId => {
|
||||||
|
const idx = TAB_ORDER.indexOf(current)
|
||||||
|
return TAB_ORDER[(idx + 1) % TAB_ORDER.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPrevTab = (current: TabId): TabId => {
|
||||||
|
const idx = TAB_ORDER.indexOf(current)
|
||||||
|
return TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboard((key) => {
|
||||||
|
// Always allow quit
|
||||||
|
if (key.ctrl && key.name === "q") {
|
||||||
|
renderer.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip global shortcuts if input is focused (let input handle keys)
|
||||||
|
if (options.inputFocused) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab navigation with left/right arrows OR [ and ]
|
||||||
|
if (key.name === "right" || key.name === "]") {
|
||||||
|
options.onTabChange(getNextTab(options.activeTab))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "left" || key.name === "[") {
|
||||||
|
options.onTabChange(getPrevTab(options.activeTab))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number keys for direct tab access (1-5)
|
||||||
|
if (key.name === "1") {
|
||||||
|
options.onTabChange("discover")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "2") {
|
||||||
|
options.onTabChange("feeds")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "3") {
|
||||||
|
options.onTabChange("search")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "4") {
|
||||||
|
options.onTabChange("player")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "5") {
|
||||||
|
options.onTabChange("settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab key cycles tabs (Shift+Tab goes backwards)
|
||||||
|
if (key.name === "tab") {
|
||||||
|
if (key.shift) {
|
||||||
|
options.onTabChange(getPrevTab(options.activeTab))
|
||||||
|
} else {
|
||||||
|
options.onTabChange(getNextTab(options.activeTab))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward other actions
|
||||||
|
if (options.onAction) {
|
||||||
|
if (key.ctrl && key.name === "s") {
|
||||||
|
options.onAction("save")
|
||||||
|
} else if (key.ctrl && key.name === "f") {
|
||||||
|
options.onAction("find")
|
||||||
|
} else if (key.name === "escape") {
|
||||||
|
options.onAction("escape")
|
||||||
|
} else if (key.name === "?" || (key.shift && key.name === "/")) {
|
||||||
|
options.onAction("help")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
37
src/hooks/useKeyboardShortcuts.ts
Normal file
37
src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||||
|
|
||||||
|
type ShortcutOptions = {
|
||||||
|
onSave?: () => void
|
||||||
|
onQuit?: () => void
|
||||||
|
onTabNext?: () => void
|
||||||
|
onTabPrev?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(options: ShortcutOptions) {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
|
||||||
|
useKeyboard((key) => {
|
||||||
|
if (key.ctrl && key.name === "q") {
|
||||||
|
if (options.onQuit) {
|
||||||
|
options.onQuit()
|
||||||
|
} else {
|
||||||
|
renderer.destroy()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.ctrl && key.name === "s") {
|
||||||
|
options.onSave?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "right") {
|
||||||
|
options.onTabNext?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "left") {
|
||||||
|
options.onTabPrev?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
4
src/index.tsx
Normal file
4
src/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { render } from "@opentui/solid"
|
||||||
|
import { App } from "./App"
|
||||||
|
|
||||||
|
render(() => <App />)
|
||||||
27
src/opentui-jsx.d.ts
vendored
Normal file
27
src/opentui-jsx.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
declare namespace JSX {
|
||||||
|
type Element = any
|
||||||
|
interface IntrinsicElements {
|
||||||
|
box: any
|
||||||
|
text: any
|
||||||
|
span: any
|
||||||
|
input: any
|
||||||
|
select: any
|
||||||
|
textarea: any
|
||||||
|
scrollbox: any
|
||||||
|
tab_select: any
|
||||||
|
ascii_font: any
|
||||||
|
code: any
|
||||||
|
diff: any
|
||||||
|
line_number: any
|
||||||
|
markdown: any
|
||||||
|
b: any
|
||||||
|
strong: any
|
||||||
|
i: any
|
||||||
|
em: any
|
||||||
|
u: any
|
||||||
|
br: any
|
||||||
|
a: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
244
src/stores/auth.ts
Normal file
244
src/stores/auth.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Authentication store for PodTUI
|
||||||
|
* Uses Zustand for state management with localStorage persistence
|
||||||
|
* Authentication is DISABLED by default
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type {
|
||||||
|
User,
|
||||||
|
AuthState,
|
||||||
|
AuthError,
|
||||||
|
AuthErrorCode,
|
||||||
|
LoginCredentials,
|
||||||
|
AuthScreen,
|
||||||
|
} from "../types/auth"
|
||||||
|
import { AUTH_CONFIG, DEFAULT_AUTH_ENABLED } from "../config/auth"
|
||||||
|
|
||||||
|
/** Initial auth state */
|
||||||
|
const initialState: AuthState = {
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load auth state from localStorage */
|
||||||
|
function loadAuthState(): AuthState {
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return initialState
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(AUTH_CONFIG.storage.authState)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Convert date strings back to Date objects
|
||||||
|
if (parsed.user?.createdAt) {
|
||||||
|
parsed.user.createdAt = new Date(parsed.user.createdAt)
|
||||||
|
}
|
||||||
|
if (parsed.user?.lastLoginAt) {
|
||||||
|
parsed.user.lastLoginAt = new Date(parsed.user.lastLoginAt)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors, use initial state
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save auth state to localStorage */
|
||||||
|
function saveAuthState(state: AuthState): void {
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTH_CONFIG.storage.authState, JSON.stringify(state))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create auth store using Solid signals */
|
||||||
|
export function createAuthStore() {
|
||||||
|
const [state, setState] = createSignal<AuthState>(loadAuthState())
|
||||||
|
const [authEnabled, setAuthEnabled] = createSignal(DEFAULT_AUTH_ENABLED)
|
||||||
|
const [currentScreen, setCurrentScreen] = createSignal<AuthScreen>("login")
|
||||||
|
|
||||||
|
/** Update state and persist */
|
||||||
|
const updateState = (updates: Partial<AuthState>) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = { ...prev, ...updates }
|
||||||
|
saveAuthState(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login with email/password (placeholder - no real backend) */
|
||||||
|
const login = async (credentials: LoginCredentials): Promise<boolean> => {
|
||||||
|
updateState({ isLoading: true, error: null })
|
||||||
|
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!AUTH_CONFIG.email.pattern.test(credentials.email)) {
|
||||||
|
updateState({
|
||||||
|
isLoading: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_CREDENTIALS" as AuthErrorCode,
|
||||||
|
message: "Invalid email format",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (credentials.password.length < AUTH_CONFIG.password.minLength) {
|
||||||
|
updateState({
|
||||||
|
isLoading: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_CREDENTIALS" as AuthErrorCode,
|
||||||
|
message: `Password must be at least ${AUTH_CONFIG.password.minLength} characters`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock user (in real app, this would validate against backend)
|
||||||
|
const user: User = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
email: credentials.email,
|
||||||
|
name: credentials.email.split("@")[0],
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
syncEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logout and clear state */
|
||||||
|
const logout = () => {
|
||||||
|
updateState({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
setCurrentScreen("login")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate 8-character code */
|
||||||
|
const validateCode = async (code: string): Promise<boolean> => {
|
||||||
|
updateState({ isLoading: true, error: null })
|
||||||
|
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
|
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||||
|
|
||||||
|
// Check code length
|
||||||
|
if (normalizedCode.length !== AUTH_CONFIG.codeValidation.codeLength) {
|
||||||
|
updateState({
|
||||||
|
isLoading: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_CODE" as AuthErrorCode,
|
||||||
|
message: `Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check code format
|
||||||
|
if (!AUTH_CONFIG.codeValidation.allowedChars.test(normalizedCode)) {
|
||||||
|
updateState({
|
||||||
|
isLoading: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_CODE" as AuthErrorCode,
|
||||||
|
message: "Code must contain only letters and numbers",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock successful code validation
|
||||||
|
const user: User = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
email: `sync-${normalizedCode.toLowerCase()}@podtui.local`,
|
||||||
|
name: `Sync User (${normalizedCode.slice(0, 4)})`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
syncEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear error */
|
||||||
|
const clearError = () => {
|
||||||
|
updateState({ error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable/disable auth */
|
||||||
|
const toggleAuthEnabled = () => {
|
||||||
|
setAuthEnabled((prev) => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State accessors (signals)
|
||||||
|
state,
|
||||||
|
authEnabled,
|
||||||
|
currentScreen,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
validateCode,
|
||||||
|
clearError,
|
||||||
|
setCurrentScreen,
|
||||||
|
toggleAuthEnabled,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
get user() {
|
||||||
|
return state().user
|
||||||
|
},
|
||||||
|
get isAuthenticated() {
|
||||||
|
return state().isAuthenticated
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return state().isLoading
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return state().error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton auth store instance */
|
||||||
|
let authStoreInstance: ReturnType<typeof createAuthStore> | null = null
|
||||||
|
|
||||||
|
/** Get or create auth store */
|
||||||
|
export function useAuthStore() {
|
||||||
|
if (!authStoreInstance) {
|
||||||
|
authStoreInstance = createAuthStore()
|
||||||
|
}
|
||||||
|
return authStoreInstance
|
||||||
|
}
|
||||||
215
src/stores/discover.ts
Normal file
215
src/stores/discover.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Discover store for PodTUI
|
||||||
|
* Manages trending/popular podcasts and category filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
|
||||||
|
export interface DiscoverCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||||
|
{ id: "all", name: "All", icon: "*" },
|
||||||
|
{ id: "technology", name: "Technology", icon: ">" },
|
||||||
|
{ id: "science", name: "Science", icon: "~" },
|
||||||
|
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||||
|
{ id: "news", name: "News", icon: "!" },
|
||||||
|
{ id: "business", name: "Business", icon: "$" },
|
||||||
|
{ id: "health", name: "Health", icon: "+" },
|
||||||
|
{ id: "education", name: "Education", icon: "?" },
|
||||||
|
{ id: "sports", name: "Sports", icon: "#" },
|
||||||
|
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||||
|
{ id: "arts", name: "Arts", icon: "@" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Mock trending podcasts */
|
||||||
|
const TRENDING_PODCASTS: Podcast[] = [
|
||||||
|
{
|
||||||
|
id: "trend-1",
|
||||||
|
title: "AI Today",
|
||||||
|
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
||||||
|
feedUrl: "https://example.com/aitoday.rss",
|
||||||
|
author: "Tech Futures",
|
||||||
|
categories: ["Technology", "Science"],
|
||||||
|
coverUrl: undefined,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-2",
|
||||||
|
title: "The History Hour",
|
||||||
|
description: "Fascinating stories from history that shaped our world today.",
|
||||||
|
feedUrl: "https://example.com/historyhour.rss",
|
||||||
|
author: "History Channel",
|
||||||
|
categories: ["Education", "History"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-3",
|
||||||
|
title: "Comedy Gold",
|
||||||
|
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
||||||
|
feedUrl: "https://example.com/comedygold.rss",
|
||||||
|
author: "Laugh Factory",
|
||||||
|
categories: ["Comedy", "Entertainment"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-4",
|
||||||
|
title: "Market Watch",
|
||||||
|
description: "Daily financial news, stock analysis, and investing tips.",
|
||||||
|
feedUrl: "https://example.com/marketwatch.rss",
|
||||||
|
author: "Finance Daily",
|
||||||
|
categories: ["Business", "News"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-5",
|
||||||
|
title: "Science Weekly",
|
||||||
|
description: "Breaking science news and in-depth analysis of the latest research.",
|
||||||
|
feedUrl: "https://example.com/scienceweekly.rss",
|
||||||
|
author: "Science Network",
|
||||||
|
categories: ["Science", "Education"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-6",
|
||||||
|
title: "True Crime Files",
|
||||||
|
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
||||||
|
feedUrl: "https://example.com/truecrime.rss",
|
||||||
|
author: "Crime Network",
|
||||||
|
categories: ["True Crime", "Documentary"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-7",
|
||||||
|
title: "Wellness Journey",
|
||||||
|
description: "Tips for mental and physical health, meditation, and mindful living.",
|
||||||
|
feedUrl: "https://example.com/wellness.rss",
|
||||||
|
author: "Health Media",
|
||||||
|
categories: ["Health", "Self-Help"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-8",
|
||||||
|
title: "Sports Talk Live",
|
||||||
|
description: "Live commentary, analysis, and interviews from the world of sports.",
|
||||||
|
feedUrl: "https://example.com/sportstalk.rss",
|
||||||
|
author: "Sports Network",
|
||||||
|
categories: ["Sports", "News"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-9",
|
||||||
|
title: "Creative Minds",
|
||||||
|
description: "Interviews with artists, designers, and creative professionals.",
|
||||||
|
feedUrl: "https://example.com/creativeminds.rss",
|
||||||
|
author: "Arts Weekly",
|
||||||
|
categories: ["Arts", "Culture"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trend-10",
|
||||||
|
title: "Dev Talk",
|
||||||
|
description: "Software development, programming tutorials, and tech career advice.",
|
||||||
|
feedUrl: "https://example.com/devtalk.rss",
|
||||||
|
author: "Code Academy",
|
||||||
|
categories: ["Technology", "Education"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Create discover store */
|
||||||
|
export function createDiscoverStore() {
|
||||||
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
|
||||||
|
|
||||||
|
/** Get filtered podcasts by category */
|
||||||
|
const filteredPodcasts = () => {
|
||||||
|
const category = selectedCategory()
|
||||||
|
if (category === "all") {
|
||||||
|
return podcasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
return podcasts().filter((p) => {
|
||||||
|
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
||||||
|
return cats.some((c) => c.includes(category.replace("-", " ")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a podcast */
|
||||||
|
const subscribe = (podcastId: string) => {
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe from a podcast */
|
||||||
|
const unsubscribe = (podcastId: string) => {
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle subscription */
|
||||||
|
const toggleSubscription = (podcastId: string) => {
|
||||||
|
const podcast = podcasts().find((p) => p.id === podcastId)
|
||||||
|
if (podcast?.isSubscribed) {
|
||||||
|
unsubscribe(podcastId)
|
||||||
|
} else {
|
||||||
|
subscribe(podcastId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh trending podcasts (mock) */
|
||||||
|
const refresh = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
// In real app, would fetch from API
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
selectedCategory,
|
||||||
|
isLoading,
|
||||||
|
podcasts,
|
||||||
|
filteredPodcasts,
|
||||||
|
categories: DISCOVER_CATEGORIES,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSelectedCategory,
|
||||||
|
subscribe,
|
||||||
|
unsubscribe,
|
||||||
|
toggleSubscription,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton discover store */
|
||||||
|
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
|
||||||
|
|
||||||
|
export function useDiscoverStore() {
|
||||||
|
if (!discoverStoreInstance) {
|
||||||
|
discoverStoreInstance = createDiscoverStore()
|
||||||
|
}
|
||||||
|
return discoverStoreInstance
|
||||||
|
}
|
||||||
435
src/stores/feed.ts
Normal file
435
src/stores/feed.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Feed store for PodTUI
|
||||||
|
* Manages feed data, sources, and filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { FeedVisibility } from "../types/feed"
|
||||||
|
import type { Feed, FeedFilter, FeedSortField } from "../types/feed"
|
||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import type { Episode, EpisodeStatus } from "../types/episode"
|
||||||
|
import type { PodcastSource, SourceType } from "../types/source"
|
||||||
|
import { DEFAULT_SOURCES } from "../types/source"
|
||||||
|
|
||||||
|
/** Storage keys */
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
feeds: "podtui_feeds",
|
||||||
|
sources: "podtui_sources",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create initial mock feeds for demonstration */
|
||||||
|
function createMockFeeds(): Feed[] {
|
||||||
|
const now = new Date()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
podcast: {
|
||||||
|
id: "p1",
|
||||||
|
title: "The Daily Tech News",
|
||||||
|
description: "Your daily dose of technology news and insights from around the world. We cover the latest in AI, software, hardware, and digital culture.",
|
||||||
|
feedUrl: "https://example.com/tech.rss",
|
||||||
|
author: "Tech Media Inc",
|
||||||
|
categories: ["Technology", "News"],
|
||||||
|
lastUpdated: now,
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p1", 25),
|
||||||
|
visibility: "public" as FeedVisibility,
|
||||||
|
sourceId: "rss",
|
||||||
|
lastUpdated: now,
|
||||||
|
isPinned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
podcast: {
|
||||||
|
id: "p2",
|
||||||
|
title: "Code & Coffee",
|
||||||
|
description: "Weekly discussions about programming, software development, and the developer lifestyle. Best enjoyed with your morning coffee.",
|
||||||
|
feedUrl: "https://example.com/code.rss",
|
||||||
|
author: "Developer Collective",
|
||||||
|
categories: ["Technology", "Programming"],
|
||||||
|
lastUpdated: new Date(Date.now() - 86400000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p2", 50),
|
||||||
|
visibility: "private" as FeedVisibility,
|
||||||
|
sourceId: "rss",
|
||||||
|
lastUpdated: new Date(Date.now() - 86400000),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
podcast: {
|
||||||
|
id: "p3",
|
||||||
|
title: "Science Explained",
|
||||||
|
description: "Breaking down complex scientific topics for curious minds. From quantum physics to biology, we make science accessible.",
|
||||||
|
feedUrl: "https://example.com/science.rss",
|
||||||
|
author: "Science Network",
|
||||||
|
categories: ["Science", "Education"],
|
||||||
|
lastUpdated: new Date(Date.now() - 172800000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p3", 120),
|
||||||
|
visibility: "public" as FeedVisibility,
|
||||||
|
sourceId: "itunes",
|
||||||
|
lastUpdated: new Date(Date.now() - 172800000),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
podcast: {
|
||||||
|
id: "p4",
|
||||||
|
title: "History Uncovered",
|
||||||
|
description: "Deep dives into fascinating historical events and figures you never learned about in school.",
|
||||||
|
feedUrl: "https://example.com/history.rss",
|
||||||
|
author: "History Channel",
|
||||||
|
categories: ["History", "Education"],
|
||||||
|
lastUpdated: new Date(Date.now() - 259200000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p4", 80),
|
||||||
|
visibility: "public" as FeedVisibility,
|
||||||
|
sourceId: "rss",
|
||||||
|
lastUpdated: new Date(Date.now() - 259200000),
|
||||||
|
isPinned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
podcast: {
|
||||||
|
id: "p5",
|
||||||
|
title: "Startup Stories",
|
||||||
|
description: "Founders share their journey from idea to exit. Learn from their successes and failures.",
|
||||||
|
feedUrl: "https://example.com/startup.rss",
|
||||||
|
author: "Entrepreneur Media",
|
||||||
|
categories: ["Business", "Technology"],
|
||||||
|
lastUpdated: new Date(Date.now() - 345600000),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: createMockEpisodes("p5", 45),
|
||||||
|
visibility: "private" as FeedVisibility,
|
||||||
|
sourceId: "itunes",
|
||||||
|
lastUpdated: new Date(Date.now() - 345600000),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create mock episodes for a podcast */
|
||||||
|
function createMockEpisodes(podcastId: string, count: number): Episode[] {
|
||||||
|
const episodes: Episode[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
episodes.push({
|
||||||
|
id: `${podcastId}-ep-${i + 1}`,
|
||||||
|
podcastId,
|
||||||
|
title: `Episode ${count - i}: Sample Episode Title`,
|
||||||
|
description: `This is the description for episode ${count - i}. It contains interesting content about various topics.`,
|
||||||
|
audioUrl: `https://example.com/audio/${podcastId}/${i + 1}.mp3`,
|
||||||
|
duration: 1800 + Math.random() * 3600, // 30-90 minutes
|
||||||
|
pubDate: new Date(Date.now() - i * 604800000), // Weekly episodes
|
||||||
|
episodeNumber: count - i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return episodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load feeds from localStorage */
|
||||||
|
function loadFeeds(): Feed[] {
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return createMockFeeds()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.feeds)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Convert date strings
|
||||||
|
return parsed.map((feed: Feed) => ({
|
||||||
|
...feed,
|
||||||
|
lastUpdated: new Date(feed.lastUpdated),
|
||||||
|
podcast: {
|
||||||
|
...feed.podcast,
|
||||||
|
lastUpdated: new Date(feed.podcast.lastUpdated),
|
||||||
|
},
|
||||||
|
episodes: feed.episodes.map((ep: Episode) => ({
|
||||||
|
...ep,
|
||||||
|
pubDate: new Date(ep.pubDate),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMockFeeds()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save feeds to localStorage */
|
||||||
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load sources from localStorage */
|
||||||
|
function loadSources(): PodcastSource[] {
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return [...DEFAULT_SOURCES]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.sources)
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...DEFAULT_SOURCES]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save sources to localStorage */
|
||||||
|
function saveSources(sources: PodcastSource[]): void {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.sources, JSON.stringify(sources))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create feed store */
|
||||||
|
export function createFeedStore() {
|
||||||
|
const [feeds, setFeeds] = createSignal<Feed[]>(loadFeeds())
|
||||||
|
const [sources, setSources] = createSignal<PodcastSource[]>(loadSources())
|
||||||
|
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||||
|
visibility: "all",
|
||||||
|
sortBy: "updated" as FeedSortField,
|
||||||
|
sortDirection: "desc",
|
||||||
|
})
|
||||||
|
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
/** Get filtered and sorted feeds */
|
||||||
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
|
let result = [...feeds()]
|
||||||
|
const f = filter()
|
||||||
|
|
||||||
|
// Filter by visibility
|
||||||
|
if (f.visibility && f.visibility !== "all") {
|
||||||
|
result = result.filter((feed) => feed.visibility === f.visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by source
|
||||||
|
if (f.sourceId) {
|
||||||
|
result = result.filter((feed) => feed.sourceId === f.sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by pinned
|
||||||
|
if (f.pinnedOnly) {
|
||||||
|
result = result.filter((feed) => feed.isPinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (f.searchQuery) {
|
||||||
|
const query = f.searchQuery.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
(feed) =>
|
||||||
|
feed.podcast.title.toLowerCase().includes(query) ||
|
||||||
|
feed.customName?.toLowerCase().includes(query) ||
|
||||||
|
feed.podcast.description?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by selected field
|
||||||
|
const sortDir = f.sortDirection === "asc" ? 1 : -1
|
||||||
|
result.sort((a, b) => {
|
||||||
|
switch (f.sortBy) {
|
||||||
|
case "title":
|
||||||
|
return sortDir * (a.customName || a.podcast.title).localeCompare(b.customName || b.podcast.title)
|
||||||
|
case "episodeCount":
|
||||||
|
return sortDir * (a.episodes.length - b.episodes.length)
|
||||||
|
case "latestEpisode":
|
||||||
|
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0
|
||||||
|
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0
|
||||||
|
return sortDir * (aLatest - bLatest)
|
||||||
|
case "updated":
|
||||||
|
default:
|
||||||
|
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pinned feeds always first
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.isPinned && !b.isPinned) return -1
|
||||||
|
if (!a.isPinned && b.isPinned) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get episodes in reverse chronological order across all feeds */
|
||||||
|
const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed }> => {
|
||||||
|
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []
|
||||||
|
|
||||||
|
for (const feed of feeds()) {
|
||||||
|
for (const episode of feed.episodes) {
|
||||||
|
allEpisodes.push({ episode, feed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by publication date (newest first)
|
||||||
|
allEpisodes.sort((a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime())
|
||||||
|
|
||||||
|
return allEpisodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a new feed */
|
||||||
|
const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
||||||
|
const newFeed: Feed = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
podcast,
|
||||||
|
episodes: [],
|
||||||
|
visibility,
|
||||||
|
sourceId,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isPinned: false,
|
||||||
|
}
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = [...prev, newFeed]
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
return newFeed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a feed */
|
||||||
|
const removeFeed = (feedId: string) => {
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.filter((f) => f.id !== feedId)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a feed */
|
||||||
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f
|
||||||
|
)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle feed pinned status */
|
||||||
|
const togglePinned = (feedId: string) => {
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f
|
||||||
|
)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a source */
|
||||||
|
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||||
|
const newSource: PodcastSource = {
|
||||||
|
...source,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
}
|
||||||
|
setSources((prev) => {
|
||||||
|
const updated = [...prev, newSource]
|
||||||
|
saveSources(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
return newSource
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a source */
|
||||||
|
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||||
|
setSources((prev) => {
|
||||||
|
const updated = prev.map((source) =>
|
||||||
|
source.id === sourceId ? { ...source, ...updates } : source
|
||||||
|
)
|
||||||
|
saveSources(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a source */
|
||||||
|
const removeSource = (sourceId: string) => {
|
||||||
|
// Don't remove default sources
|
||||||
|
if (sourceId === "itunes" || sourceId === "rss") return false
|
||||||
|
|
||||||
|
setSources((prev) => {
|
||||||
|
const updated = prev.filter((s) => s.id !== sourceId)
|
||||||
|
saveSources(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle source enabled status */
|
||||||
|
const toggleSource = (sourceId: string) => {
|
||||||
|
setSources((prev) => {
|
||||||
|
const updated = prev.map((s) =>
|
||||||
|
s.id === sourceId ? { ...s, enabled: !s.enabled } : s
|
||||||
|
)
|
||||||
|
saveSources(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get feed by ID */
|
||||||
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
|
return feeds().find((f) => f.id === feedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get selected feed */
|
||||||
|
const getSelectedFeed = (): Feed | undefined => {
|
||||||
|
const id = selectedFeedId()
|
||||||
|
return id ? getFeed(id) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
feeds,
|
||||||
|
sources,
|
||||||
|
filter,
|
||||||
|
selectedFeedId,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
getFilteredFeeds,
|
||||||
|
getAllEpisodesChronological,
|
||||||
|
getFeed,
|
||||||
|
getSelectedFeed,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setFilter,
|
||||||
|
setSelectedFeedId,
|
||||||
|
addFeed,
|
||||||
|
removeFeed,
|
||||||
|
updateFeed,
|
||||||
|
togglePinned,
|
||||||
|
addSource,
|
||||||
|
removeSource,
|
||||||
|
toggleSource,
|
||||||
|
updateSource,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton feed store */
|
||||||
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null
|
||||||
|
|
||||||
|
export function useFeedStore() {
|
||||||
|
if (!feedStoreInstance) {
|
||||||
|
feedStoreInstance = createFeedStore()
|
||||||
|
}
|
||||||
|
return feedStoreInstance
|
||||||
|
}
|
||||||
187
src/stores/search.ts
Normal file
187
src/stores/search.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Search store for PodTUI
|
||||||
|
* Manages search state, history, and results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { searchPodcasts } from "../utils/search"
|
||||||
|
import { useFeedStore } from "./feed"
|
||||||
|
import type { SearchResult } from "../types/source"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "podtui_search_history"
|
||||||
|
const MAX_HISTORY = 20
|
||||||
|
|
||||||
|
export interface SearchState {
|
||||||
|
query: string
|
||||||
|
isSearching: boolean
|
||||||
|
results: SearchResult[]
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL = 1000 * 60 * 5
|
||||||
|
|
||||||
|
/** Load search history from localStorage */
|
||||||
|
function loadHistory(): string[] {
|
||||||
|
if (typeof localStorage === "undefined") return []
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return stored ? JSON.parse(stored) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save search history to localStorage */
|
||||||
|
function saveHistory(history: string[]): void {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create search store */
|
||||||
|
export function createSearchStore() {
|
||||||
|
const feedStore = useFeedStore()
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const [isSearching, setIsSearching] = createSignal(false)
|
||||||
|
const [results, setResults] = createSignal<SearchResult[]>([])
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [history, setHistory] = createSignal<string[]>(loadHistory())
|
||||||
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
|
||||||
|
|
||||||
|
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
||||||
|
const feeds = feedStore.feeds()
|
||||||
|
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl))
|
||||||
|
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id))
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
podcast: {
|
||||||
|
...item.podcast,
|
||||||
|
isSubscribed:
|
||||||
|
item.podcast.isSubscribed ||
|
||||||
|
subscribedUrls.has(item.podcast.feedUrl) ||
|
||||||
|
subscribedIds.has(item.podcast.id),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Perform search (multi-source implementation) */
|
||||||
|
const search = async (searchQuery: string): Promise<void> => {
|
||||||
|
const q = searchQuery.trim()
|
||||||
|
if (!q) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuery(q)
|
||||||
|
setIsSearching(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
addToHistory(q)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sources = feedStore.sources()
|
||||||
|
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id)
|
||||||
|
const sourceIds = selectedSources().length > 0
|
||||||
|
? selectedSources()
|
||||||
|
: enabledSourceIds
|
||||||
|
|
||||||
|
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
||||||
|
cacheTtl: CACHE_TTL,
|
||||||
|
})
|
||||||
|
|
||||||
|
setResults(applySubscribedStatus(searchResults))
|
||||||
|
} catch (e) {
|
||||||
|
setError("Search failed. Please try again.")
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add query to history */
|
||||||
|
const addToHistory = (q: string) => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
// Remove duplicates and add to front
|
||||||
|
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase())
|
||||||
|
const updated = [q, ...filtered].slice(0, MAX_HISTORY)
|
||||||
|
saveHistory(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear search history */
|
||||||
|
const clearHistory = () => {
|
||||||
|
setHistory([])
|
||||||
|
saveHistory([])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove single history item */
|
||||||
|
const removeFromHistory = (q: string) => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
const updated = prev.filter((h) => h !== q)
|
||||||
|
saveHistory(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear results */
|
||||||
|
const clearResults = () => {
|
||||||
|
setResults([])
|
||||||
|
setQuery("")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a podcast as subscribed in results */
|
||||||
|
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
|
setResults((prev) =>
|
||||||
|
prev.map((result) => {
|
||||||
|
const matchesId = result.podcast.id === podcastId
|
||||||
|
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false
|
||||||
|
if (matchesId || matchesUrl) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
podcast: {
|
||||||
|
...result.podcast,
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
query,
|
||||||
|
isSearching,
|
||||||
|
results,
|
||||||
|
error,
|
||||||
|
history,
|
||||||
|
selectedSources,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
search,
|
||||||
|
setQuery,
|
||||||
|
clearResults,
|
||||||
|
clearHistory,
|
||||||
|
removeFromHistory,
|
||||||
|
setSelectedSources,
|
||||||
|
markSubscribed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton search store */
|
||||||
|
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null
|
||||||
|
|
||||||
|
export function useSearchStore() {
|
||||||
|
if (!searchStoreInstance) {
|
||||||
|
searchStoreInstance = createSearchStore()
|
||||||
|
}
|
||||||
|
return searchStoreInstance
|
||||||
|
}
|
||||||
65
src/types/auth.ts
Normal file
65
src/types/auth.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Authentication types for PodTUI
|
||||||
|
* Authentication is optional and disabled by default
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** User profile information */
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
createdAt: Date
|
||||||
|
lastLoginAt?: Date
|
||||||
|
syncEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authentication state */
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
error: AuthError | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authentication error */
|
||||||
|
export interface AuthError {
|
||||||
|
code: AuthErrorCode
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error codes for authentication */
|
||||||
|
export enum AuthErrorCode {
|
||||||
|
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
|
||||||
|
INVALID_CODE = "INVALID_CODE",
|
||||||
|
CODE_EXPIRED = "CODE_EXPIRED",
|
||||||
|
NETWORK_ERROR = "NETWORK_ERROR",
|
||||||
|
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login credentials */
|
||||||
|
export interface LoginCredentials {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Code validation request */
|
||||||
|
export interface CodeValidationRequest {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth provider types */
|
||||||
|
export enum OAuthProvider {
|
||||||
|
GOOGLE = "google",
|
||||||
|
APPLE = "apple",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth provider configuration */
|
||||||
|
export interface OAuthProviderConfig {
|
||||||
|
id: OAuthProvider
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth screen types for navigation */
|
||||||
|
export type AuthScreen = "login" | "code" | "oauth" | "profile"
|
||||||
86
src/types/episode.ts
Normal file
86
src/types/episode.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Episode type definitions for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Episode playback status */
|
||||||
|
export enum EpisodeStatus {
|
||||||
|
NOT_STARTED = "not_started",
|
||||||
|
PLAYING = "playing",
|
||||||
|
PAUSED = "paused",
|
||||||
|
COMPLETED = "completed",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Core episode information */
|
||||||
|
export interface Episode {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string
|
||||||
|
/** Parent podcast ID */
|
||||||
|
podcastId: string
|
||||||
|
/** Episode title */
|
||||||
|
title: string
|
||||||
|
/** Episode description/show notes */
|
||||||
|
description: string
|
||||||
|
/** Audio file URL */
|
||||||
|
audioUrl: string
|
||||||
|
/** Duration in seconds */
|
||||||
|
duration: number
|
||||||
|
/** Publication date */
|
||||||
|
pubDate: Date
|
||||||
|
/** Episode number (if available) */
|
||||||
|
episodeNumber?: number
|
||||||
|
/** Season number (if available) */
|
||||||
|
seasonNumber?: number
|
||||||
|
/** Episode type (full, trailer, bonus) */
|
||||||
|
episodeType?: EpisodeType
|
||||||
|
/** Whether episode is explicit */
|
||||||
|
explicit?: boolean
|
||||||
|
/** Episode image URL (if different from podcast) */
|
||||||
|
imageUrl?: string
|
||||||
|
/** File size in bytes */
|
||||||
|
fileSize?: number
|
||||||
|
/** MIME type */
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode type enumeration */
|
||||||
|
export enum EpisodeType {
|
||||||
|
FULL = "full",
|
||||||
|
TRAILER = "trailer",
|
||||||
|
BONUS = "bonus",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode playback progress */
|
||||||
|
export interface Progress {
|
||||||
|
/** Episode ID */
|
||||||
|
episodeId: string
|
||||||
|
/** Current position in seconds */
|
||||||
|
position: number
|
||||||
|
/** Total duration in seconds */
|
||||||
|
duration: number
|
||||||
|
/** Last played timestamp */
|
||||||
|
timestamp: Date
|
||||||
|
/** Playback speed (1.0 = normal) */
|
||||||
|
playbackSpeed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode with playback state */
|
||||||
|
export interface EpisodeWithProgress extends Episode {
|
||||||
|
/** Current playback status */
|
||||||
|
status: EpisodeStatus
|
||||||
|
/** Playback progress */
|
||||||
|
progress?: Progress
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode list item for display */
|
||||||
|
export interface EpisodeListItem {
|
||||||
|
/** Episode data */
|
||||||
|
episode: Episode
|
||||||
|
/** Podcast title (for display in feeds) */
|
||||||
|
podcastTitle: string
|
||||||
|
/** Podcast cover URL */
|
||||||
|
podcastCoverUrl?: string
|
||||||
|
/** Current status */
|
||||||
|
status: EpisodeStatus
|
||||||
|
/** Progress percentage (0-100) */
|
||||||
|
progressPercent: number
|
||||||
|
}
|
||||||
116
src/types/feed.ts
Normal file
116
src/types/feed.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Feed type definitions for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Podcast } from "./podcast"
|
||||||
|
import type { Episode, EpisodeStatus } from "./episode"
|
||||||
|
|
||||||
|
/** Feed visibility */
|
||||||
|
export enum FeedVisibility {
|
||||||
|
PUBLIC = "public",
|
||||||
|
PRIVATE = "private",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed information */
|
||||||
|
export interface Feed {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string
|
||||||
|
/** Associated podcast */
|
||||||
|
podcast: Podcast
|
||||||
|
/** Episodes in this feed */
|
||||||
|
episodes: Episode[]
|
||||||
|
/** Whether feed is public or private */
|
||||||
|
visibility: FeedVisibility
|
||||||
|
/** Source ID that provided this feed */
|
||||||
|
sourceId: string
|
||||||
|
/** Last updated timestamp */
|
||||||
|
lastUpdated: Date
|
||||||
|
/** Custom feed name (user-defined) */
|
||||||
|
customName?: string
|
||||||
|
/** User notes about this feed */
|
||||||
|
notes?: string
|
||||||
|
/** Whether feed is pinned/favorited */
|
||||||
|
isPinned: boolean
|
||||||
|
/** Feed color for UI */
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed item for display in lists */
|
||||||
|
export interface FeedItem {
|
||||||
|
/** Episode data */
|
||||||
|
episode: Episode
|
||||||
|
/** Parent podcast */
|
||||||
|
podcast: Podcast
|
||||||
|
/** Feed ID */
|
||||||
|
feedId: string
|
||||||
|
/** Episode status */
|
||||||
|
status: EpisodeStatus
|
||||||
|
/** Progress percentage (0-100) */
|
||||||
|
progressPercent: number
|
||||||
|
/** Whether this item is new (unplayed) */
|
||||||
|
isNew: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed filter options */
|
||||||
|
export interface FeedFilter {
|
||||||
|
/** Filter by visibility */
|
||||||
|
visibility?: FeedVisibility | "all"
|
||||||
|
/** Filter by source ID */
|
||||||
|
sourceId?: string
|
||||||
|
/** Filter by pinned status */
|
||||||
|
pinnedOnly?: boolean
|
||||||
|
/** Search query for filtering */
|
||||||
|
searchQuery?: string
|
||||||
|
/** Sort field */
|
||||||
|
sortBy?: FeedSortField
|
||||||
|
/** Sort direction */
|
||||||
|
sortDirection?: "asc" | "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed sort fields */
|
||||||
|
export enum FeedSortField {
|
||||||
|
/** Sort by last updated */
|
||||||
|
UPDATED = "updated",
|
||||||
|
/** Sort by title */
|
||||||
|
TITLE = "title",
|
||||||
|
/** Sort by episode count */
|
||||||
|
EPISODE_COUNT = "episodeCount",
|
||||||
|
/** Sort by most recent episode */
|
||||||
|
LATEST_EPISODE = "latestEpisode",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed list display options */
|
||||||
|
export interface FeedListOptions {
|
||||||
|
/** Show episode count */
|
||||||
|
showEpisodeCount: boolean
|
||||||
|
/** Show last updated */
|
||||||
|
showLastUpdated: boolean
|
||||||
|
/** Show source indicator */
|
||||||
|
showSource: boolean
|
||||||
|
/** Compact mode */
|
||||||
|
compact: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default feed list options */
|
||||||
|
export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = {
|
||||||
|
showEpisodeCount: true,
|
||||||
|
showLastUpdated: true,
|
||||||
|
showSource: false,
|
||||||
|
compact: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed statistics */
|
||||||
|
export interface FeedStats {
|
||||||
|
/** Total feed count */
|
||||||
|
totalFeeds: number
|
||||||
|
/** Public feed count */
|
||||||
|
publicFeeds: number
|
||||||
|
/** Private feed count */
|
||||||
|
privateFeeds: number
|
||||||
|
/** Total episode count across all feeds */
|
||||||
|
totalEpisodes: number
|
||||||
|
/** Unplayed episode count */
|
||||||
|
unplayedEpisodes: number
|
||||||
|
/** In-progress episode count */
|
||||||
|
inProgressEpisodes: number
|
||||||
|
}
|
||||||
40
src/types/podcast.ts
Normal file
40
src/types/podcast.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Podcast type definitions for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Core podcast information */
|
||||||
|
export interface Podcast {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string
|
||||||
|
/** Podcast title */
|
||||||
|
title: string
|
||||||
|
/** Podcast description/summary */
|
||||||
|
description: string
|
||||||
|
/** Cover image URL */
|
||||||
|
coverUrl?: string
|
||||||
|
/** RSS feed URL */
|
||||||
|
feedUrl: string
|
||||||
|
/** Author/creator name */
|
||||||
|
author?: string
|
||||||
|
/** Podcast categories */
|
||||||
|
categories?: string[]
|
||||||
|
/** Language code (e.g., 'en', 'es') */
|
||||||
|
language?: string
|
||||||
|
/** Website URL */
|
||||||
|
websiteUrl?: string
|
||||||
|
/** Last updated timestamp */
|
||||||
|
lastUpdated: Date
|
||||||
|
/** Whether the podcast is currently subscribed */
|
||||||
|
isSubscribed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Podcast with episodes included */
|
||||||
|
export interface PodcastWithEpisodes extends Podcast {
|
||||||
|
/** List of episodes */
|
||||||
|
episodes: Episode[]
|
||||||
|
/** Total episode count */
|
||||||
|
totalEpisodes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode import - needed for PodcastWithEpisodes */
|
||||||
|
import type { Episode } from "./episode"
|
||||||
116
src/types/source.ts
Normal file
116
src/types/source.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Podcast source type definitions for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Source type enumeration */
|
||||||
|
export enum SourceType {
|
||||||
|
/** RSS feed URL */
|
||||||
|
RSS = "rss",
|
||||||
|
/** API-based source (iTunes, Spotify, etc.) */
|
||||||
|
API = "api",
|
||||||
|
/** Custom/user-defined source */
|
||||||
|
CUSTOM = "custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Podcast source configuration */
|
||||||
|
export interface PodcastSource {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string
|
||||||
|
/** Source display name */
|
||||||
|
name: string
|
||||||
|
/** Source type */
|
||||||
|
type: SourceType
|
||||||
|
/** Base URL for the source */
|
||||||
|
baseUrl: string
|
||||||
|
/** API key (if required) */
|
||||||
|
apiKey?: string
|
||||||
|
/** Whether source is enabled */
|
||||||
|
enabled: boolean
|
||||||
|
/** Source icon/logo URL */
|
||||||
|
iconUrl?: string
|
||||||
|
/** Source description */
|
||||||
|
description?: string
|
||||||
|
/** Default country for source searches */
|
||||||
|
country?: string
|
||||||
|
/** Default language for search results */
|
||||||
|
language?: string
|
||||||
|
/** Include explicit results */
|
||||||
|
allowExplicit?: boolean
|
||||||
|
/** Rate limit (requests per minute) */
|
||||||
|
rateLimit?: number
|
||||||
|
/** Last successful fetch */
|
||||||
|
lastFetch?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search query configuration */
|
||||||
|
export interface SearchQuery {
|
||||||
|
/** Search query text */
|
||||||
|
query: string
|
||||||
|
/** Source IDs to search (empty = all enabled sources) */
|
||||||
|
sourceIds: string[]
|
||||||
|
/** Optional filters */
|
||||||
|
filters?: SearchFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search filters */
|
||||||
|
export interface SearchFilters {
|
||||||
|
/** Filter by language */
|
||||||
|
language?: string
|
||||||
|
/** Filter by category */
|
||||||
|
category?: string
|
||||||
|
/** Filter by explicit content */
|
||||||
|
explicit?: boolean
|
||||||
|
/** Sort by field */
|
||||||
|
sortBy?: SearchSortField
|
||||||
|
/** Sort direction */
|
||||||
|
sortDirection?: "asc" | "desc"
|
||||||
|
/** Results limit */
|
||||||
|
limit?: number
|
||||||
|
/** Results offset for pagination */
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search sort fields */
|
||||||
|
export enum SearchSortField {
|
||||||
|
RELEVANCE = "relevance",
|
||||||
|
DATE = "date",
|
||||||
|
TITLE = "title",
|
||||||
|
POPULARITY = "popularity",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search result */
|
||||||
|
export interface SearchResult {
|
||||||
|
/** Source that returned this result */
|
||||||
|
sourceId: string
|
||||||
|
/** Source display name */
|
||||||
|
sourceName?: string
|
||||||
|
/** Source type */
|
||||||
|
sourceType?: SourceType
|
||||||
|
/** Podcast data */
|
||||||
|
podcast: import("./podcast").Podcast
|
||||||
|
/** Relevance score (0-1) */
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default podcast sources */
|
||||||
|
export const DEFAULT_SOURCES: PodcastSource[] = [
|
||||||
|
{
|
||||||
|
id: "itunes",
|
||||||
|
name: "Apple Podcasts",
|
||||||
|
type: SourceType.API,
|
||||||
|
baseUrl: "https://itunes.apple.com/search",
|
||||||
|
enabled: true,
|
||||||
|
description: "Search the Apple Podcasts directory",
|
||||||
|
country: "US",
|
||||||
|
language: "en_us",
|
||||||
|
allowExplicit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rss",
|
||||||
|
name: "RSS Feed",
|
||||||
|
type: SourceType.RSS,
|
||||||
|
baseUrl: "",
|
||||||
|
enabled: true,
|
||||||
|
description: "Add podcasts via RSS feed URL",
|
||||||
|
},
|
||||||
|
]
|
||||||
24
src/types/sync-json.ts
Normal file
24
src/types/sync-json.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type SyncData = {
|
||||||
|
version: string
|
||||||
|
lastSyncedAt: string
|
||||||
|
feeds: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
isPrivate: boolean
|
||||||
|
}[]
|
||||||
|
sources: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}[]
|
||||||
|
settings: {
|
||||||
|
theme: string
|
||||||
|
playbackSpeed: number
|
||||||
|
downloadPath: string
|
||||||
|
}
|
||||||
|
preferences: {
|
||||||
|
showExplicit: boolean
|
||||||
|
autoDownload: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/types/sync-xml.ts
Normal file
28
src/types/sync-xml.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export type SyncDataXML = {
|
||||||
|
version: string
|
||||||
|
lastSyncedAt: string
|
||||||
|
feeds: {
|
||||||
|
feed: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
isPrivate: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
sources: {
|
||||||
|
source: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
settings: {
|
||||||
|
theme: string
|
||||||
|
playbackSpeed: number
|
||||||
|
downloadPath: string
|
||||||
|
}
|
||||||
|
preferences: {
|
||||||
|
showExplicit: boolean
|
||||||
|
autoDownload: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/utils/file-detector.ts
Normal file
10
src/utils/file-detector.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export type DetectedFormat = "json" | "xml" | "unknown"
|
||||||
|
|
||||||
|
export function detectFormat(filePath: string): DetectedFormat {
|
||||||
|
const length = filePath.length
|
||||||
|
const jsonSuffix = length >= 5 ? filePath.substr(length - 5) : ""
|
||||||
|
const xmlSuffix = length >= 4 ? filePath.substr(length - 4) : ""
|
||||||
|
if (jsonSuffix === ".json" || jsonSuffix === ".JSON") return "json"
|
||||||
|
if (xmlSuffix === ".xml" || xmlSuffix === ".XML") return "xml"
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
156
src/utils/search.ts
Normal file
156
src/utils/search.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { searchSourceByType } from "./source-searcher"
|
||||||
|
import type { PodcastSource, SearchResult } from "../types/source"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
|
type SearchCacheEntry = {
|
||||||
|
timestamp: number
|
||||||
|
results: SearchResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchOptions = {
|
||||||
|
cacheTtl?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchCache = new Map<string, SearchCacheEntry>()
|
||||||
|
const rateLimitState = new Map<string, number[]>()
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60000
|
||||||
|
const RATE_LIMIT_MAX_CALLS = 20
|
||||||
|
|
||||||
|
const throttleSource = async (sourceId: string) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const windowStart = now - RATE_LIMIT_WINDOW_MS
|
||||||
|
const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
||||||
|
|
||||||
|
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
||||||
|
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now
|
||||||
|
if (waitMs > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
||||||
|
updated.push(Date.now())
|
||||||
|
rateLimitState.set(sourceId, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
||||||
|
const keySources = [...sourceIds].sort().join(",")
|
||||||
|
return `${query.toLowerCase()}::${keySources}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||||
|
Date.now() - entry.timestamp < ttl
|
||||||
|
|
||||||
|
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||||
|
const map = new Map<string, SearchResult>()
|
||||||
|
for (const result of results) {
|
||||||
|
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title
|
||||||
|
const existing = map.get(key)
|
||||||
|
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||||
|
map.set(key, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchPodcasts = async (
|
||||||
|
query: string,
|
||||||
|
sourceIds: string[],
|
||||||
|
sources: PodcastSource[],
|
||||||
|
options: SearchOptions = {}
|
||||||
|
): Promise<SearchResult[]> => {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return []
|
||||||
|
|
||||||
|
const activeSources = sources.filter(
|
||||||
|
(source) => sourceIds.includes(source.id) && source.enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
if (activeSources.length === 0) return []
|
||||||
|
|
||||||
|
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5
|
||||||
|
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id))
|
||||||
|
const cached = searchCache.get(cacheKey)
|
||||||
|
if (cached && isCacheValid(cached, cacheTtl)) {
|
||||||
|
return cached.results
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = []
|
||||||
|
const errors: Error[] = []
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
activeSources.map(async (source) => {
|
||||||
|
try {
|
||||||
|
await throttleSource(source.id)
|
||||||
|
const sourceResults = await searchSourceByType(trimmed, source)
|
||||||
|
results.push(...sourceResults)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error as Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const deduped = dedupeResults(results)
|
||||||
|
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||||
|
|
||||||
|
if (sorted.length === 0 && errors.length > 0) {
|
||||||
|
throw new Error("Search failed for all sources")
|
||||||
|
}
|
||||||
|
|
||||||
|
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted })
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesEpisodeResult = {
|
||||||
|
trackId?: number
|
||||||
|
trackName?: string
|
||||||
|
description?: string
|
||||||
|
shortDescription?: string
|
||||||
|
releaseDate?: string
|
||||||
|
trackTimeMillis?: number
|
||||||
|
episodeUrl?: string
|
||||||
|
previewUrl?: string
|
||||||
|
trackViewUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesEpisodeResponse = {
|
||||||
|
resultCount: number
|
||||||
|
results: ItunesEpisodeResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchEpisodes = async (
|
||||||
|
query: string,
|
||||||
|
feedId: string
|
||||||
|
): Promise<Episode[]> => {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return []
|
||||||
|
|
||||||
|
const url = new URL("https://itunes.apple.com/search")
|
||||||
|
url.searchParams.set("term", trimmed)
|
||||||
|
url.searchParams.set("media", "podcast")
|
||||||
|
url.searchParams.set("entity", "podcastEpisode")
|
||||||
|
url.searchParams.set("country", "US")
|
||||||
|
url.searchParams.set("lang", "en_us")
|
||||||
|
|
||||||
|
const response = await fetch(url.toString())
|
||||||
|
if (!response.ok) return []
|
||||||
|
|
||||||
|
const data = (await response.json()) as ItunesEpisodeResponse
|
||||||
|
return data.results
|
||||||
|
.map((item) => {
|
||||||
|
if (!item.trackName) return null
|
||||||
|
const id = item.trackId ? `episode-${item.trackId}` : `episode-${item.trackName}`
|
||||||
|
const audioUrl = item.episodeUrl || item.previewUrl || item.trackViewUrl || ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
podcastId: feedId,
|
||||||
|
title: item.trackName,
|
||||||
|
description: item.description || item.shortDescription || "",
|
||||||
|
audioUrl,
|
||||||
|
duration: item.trackTimeMillis ? Math.round(item.trackTimeMillis / 1000) : 0,
|
||||||
|
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is Episode => Boolean(item))
|
||||||
|
}
|
||||||
195
src/utils/source-searcher.ts
Normal file
195
src/utils/source-searcher.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type { Podcast } from "../types/podcast"
|
||||||
|
import { SourceType } from "../types/source"
|
||||||
|
import type { PodcastSource, SearchResult } from "../types/source"
|
||||||
|
|
||||||
|
type SearcherResult = SearchResult[]
|
||||||
|
|
||||||
|
const delay = async (min = 200, max = 500) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, min + Math.random() * max))
|
||||||
|
|
||||||
|
const hashString = (input: string): number => {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + input.charCodeAt(i)
|
||||||
|
hash |= 0
|
||||||
|
}
|
||||||
|
return Math.abs(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugify = (input: string): string =>
|
||||||
|
input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
|
||||||
|
const sourceLabel = (source: PodcastSource): string =>
|
||||||
|
source.name || source.id
|
||||||
|
|
||||||
|
const buildPodcast = (
|
||||||
|
idBase: string,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
author: string,
|
||||||
|
categories: string[],
|
||||||
|
source: PodcastSource
|
||||||
|
): Podcast => ({
|
||||||
|
id: idBase,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
feedUrl: `https://example.com/${slugify(title)}/feed.xml`,
|
||||||
|
author,
|
||||||
|
categories,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeResults = (query: string, source: PodcastSource, seedOffset = 0): SearcherResult => {
|
||||||
|
const seed = hashString(`${source.id}:${query}`) + seedOffset
|
||||||
|
const baseTitles = [
|
||||||
|
"Daily Briefing",
|
||||||
|
"Studio Sessions",
|
||||||
|
"Signal & Noise",
|
||||||
|
"The Long Play",
|
||||||
|
"Off the Record",
|
||||||
|
]
|
||||||
|
const descriptors = [
|
||||||
|
"Deep dives into",
|
||||||
|
"A fast-paced look at",
|
||||||
|
"Smart conversations about",
|
||||||
|
"A weekly roundup of",
|
||||||
|
"Curated stories on",
|
||||||
|
]
|
||||||
|
const categories = ["Technology", "Business", "Science", "Culture", "News"]
|
||||||
|
|
||||||
|
return baseTitles.map((base, index) => {
|
||||||
|
const title = `${query} ${base}`
|
||||||
|
const desc = `${descriptors[index % descriptors.length]} ${query.toLowerCase()} from ${sourceLabel(source)}.`
|
||||||
|
const author = `${sourceLabel(source)} Network`
|
||||||
|
const cat = [categories[(seed + index) % categories.length]]
|
||||||
|
const podcast = buildPodcast(
|
||||||
|
`search-${source.id}-${seed + index}`,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
author,
|
||||||
|
cat,
|
||||||
|
source
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
sourceType: source.type,
|
||||||
|
podcast,
|
||||||
|
score: 1 - index * 0.08,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchRSSSource = async (
|
||||||
|
query: string,
|
||||||
|
source: PodcastSource
|
||||||
|
): Promise<SearcherResult> => {
|
||||||
|
await delay(200, 450)
|
||||||
|
return makeResults(query, source, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesResult = {
|
||||||
|
collectionId?: number
|
||||||
|
collectionName?: string
|
||||||
|
artistName?: string
|
||||||
|
feedUrl?: string
|
||||||
|
artworkUrl100?: string
|
||||||
|
artworkUrl600?: string
|
||||||
|
primaryGenreName?: string
|
||||||
|
releaseDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesResponse = {
|
||||||
|
resultCount: number
|
||||||
|
results: ItunesResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildItunesUrl = (query: string, source: PodcastSource) => {
|
||||||
|
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
const params = url.searchParams
|
||||||
|
|
||||||
|
params.set("term", query.trim())
|
||||||
|
params.set("media", "podcast")
|
||||||
|
params.set("entity", "podcast")
|
||||||
|
params.set("country", source.country ?? "US")
|
||||||
|
params.set("lang", source.language ?? "en_us")
|
||||||
|
params.set("explicit", source.allowExplicit === false ? "No" : "Yes")
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
|
||||||
|
if (!result.collectionName || !result.feedUrl) return null
|
||||||
|
|
||||||
|
const id = result.collectionId
|
||||||
|
? `itunes-${result.collectionId}`
|
||||||
|
: `itunes-${slugify(result.collectionName)}`
|
||||||
|
|
||||||
|
const descriptionParts = [result.collectionName]
|
||||||
|
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
|
||||||
|
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: result.collectionName,
|
||||||
|
description: descriptionParts.join(" • "),
|
||||||
|
feedUrl: result.feedUrl,
|
||||||
|
author: result.artistName,
|
||||||
|
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
|
||||||
|
coverUrl: result.artworkUrl600 || result.artworkUrl100,
|
||||||
|
lastUpdated: result.releaseDate ? new Date(result.releaseDate) : new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchAPISource = async (
|
||||||
|
query: string,
|
||||||
|
source: PodcastSource
|
||||||
|
): Promise<SearcherResult> => {
|
||||||
|
const url = buildItunesUrl(query, source)
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`iTunes search failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ItunesResponse
|
||||||
|
const results = data.results
|
||||||
|
.map((item) => mapItunesResult(item, source))
|
||||||
|
.filter((item): item is Podcast => Boolean(item))
|
||||||
|
|
||||||
|
return results.map((podcast, index) => ({
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
sourceType: source.type,
|
||||||
|
podcast,
|
||||||
|
score: 1 - index * 0.02,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchCustomSource = async (
|
||||||
|
query: string,
|
||||||
|
source: PodcastSource
|
||||||
|
): Promise<SearcherResult> => {
|
||||||
|
await delay(300, 650)
|
||||||
|
return makeResults(query, source, 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchSourceByType = async (
|
||||||
|
query: string,
|
||||||
|
source: PodcastSource
|
||||||
|
): Promise<SearcherResult> => {
|
||||||
|
if (source.type === SourceType.RSS) {
|
||||||
|
return searchRSSSource(query, source)
|
||||||
|
}
|
||||||
|
if (source.type === SourceType.CUSTOM) {
|
||||||
|
return searchCustomSource(query, source)
|
||||||
|
}
|
||||||
|
return searchAPISource(query, source)
|
||||||
|
}
|
||||||
25
src/utils/sync-validation.ts
Normal file
25
src/utils/sync-validation.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { SyncData } from "../types/sync-json"
|
||||||
|
import type { SyncDataXML } from "../types/sync-xml"
|
||||||
|
import { syncFormats } from "../constants/sync-formats"
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is { [key: string]: unknown } =>
|
||||||
|
typeof value === "object" && value !== null
|
||||||
|
|
||||||
|
const hasVersion = (value: unknown): value is { version: string } =>
|
||||||
|
isObject(value) && typeof value.version === "string"
|
||||||
|
|
||||||
|
export function validateJSONSync(data: unknown): SyncData {
|
||||||
|
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
|
||||||
|
throw { message: "Unsupported sync format" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as SyncData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateXMLSync(data: unknown): SyncDataXML {
|
||||||
|
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
|
||||||
|
throw { message: "Unsupported sync format" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as SyncDataXML
|
||||||
|
}
|
||||||
59
src/utils/sync.ts
Normal file
59
src/utils/sync.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { SyncData } from "../types/sync-json"
|
||||||
|
import type { SyncDataXML } from "../types/sync-xml"
|
||||||
|
import { validateJSONSync, validateXMLSync } from "./sync-validation"
|
||||||
|
import { syncFormats } from "../constants/sync-formats"
|
||||||
|
|
||||||
|
export function exportToJSON(data: SyncData): string {
|
||||||
|
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importFromJSON(json: string): SyncData {
|
||||||
|
const data = json
|
||||||
|
return validateJSONSync(data as unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToXML(data: SyncDataXML): string {
|
||||||
|
const feedItems = ""
|
||||||
|
const sourceItems = ""
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||||
|
`<podcastSync version="${syncFormats.xml.version}">\n` +
|
||||||
|
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
|
||||||
|
` <feeds>\n` +
|
||||||
|
feedItems +
|
||||||
|
` </feeds>\n` +
|
||||||
|
` <sources>\n` +
|
||||||
|
sourceItems +
|
||||||
|
` </sources>\n` +
|
||||||
|
` <settings>\n` +
|
||||||
|
` <theme>${data.settings.theme}</theme>\n` +
|
||||||
|
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
|
||||||
|
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
|
||||||
|
` </settings>\n` +
|
||||||
|
` <preferences>\n` +
|
||||||
|
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
|
||||||
|
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
|
||||||
|
` </preferences>\n` +
|
||||||
|
`</podcastSync>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importFromXML(xml: string): SyncDataXML {
|
||||||
|
const version = syncFormats.xml.version
|
||||||
|
const data = {
|
||||||
|
version,
|
||||||
|
lastSyncedAt: "",
|
||||||
|
feeds: { feed: [] },
|
||||||
|
sources: { source: [] },
|
||||||
|
settings: {
|
||||||
|
theme: "system",
|
||||||
|
playbackSpeed: 1,
|
||||||
|
downloadPath: "",
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
showExplicit: false,
|
||||||
|
autoDownload: false,
|
||||||
|
},
|
||||||
|
} as SyncDataXML
|
||||||
|
|
||||||
|
return validateXMLSync(data)
|
||||||
|
}
|
||||||
@@ -9,10 +9,10 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
## Phase 1: Project Foundation 🏗️
|
## Phase 1: Project Foundation 🏗️
|
||||||
**Setup and configure the development environment**
|
**Setup and configure the development environment**
|
||||||
|
|
||||||
- [ ] 01 — Initialize SolidJS OpenTUI project with Bun → `01-project-setup.md`
|
- [x] 01 — Initialize SolidJS OpenTUI project with Bun → `01-project-setup.md`
|
||||||
- [ ] 13 — Set up TypeScript configuration and build system → `13-typescript-config.md`
|
- [x] 13 — Set up TypeScript configuration and build system → `13-typescript-config.md`
|
||||||
- [ ] 14 — Create project directory structure and dependencies → `14-project-structure.md`
|
- [x] 14 — Create project directory structure and dependencies → `14-project-structure.md`
|
||||||
- [ ] 15 — Build responsive layout system (Flexbox) → `15-responsive-layout.md`
|
- [x] 15 — Build responsive layout system (Flexbox) → `15-responsive-layout.md`
|
||||||
|
|
||||||
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
## Phase 2: Core Architecture 🏗️
|
## Phase 2: Core Architecture 🏗️
|
||||||
**Build the main application shell and navigation**
|
**Build the main application shell and navigation**
|
||||||
|
|
||||||
- [ ] 02 — Create main app shell with tab navigation → `02-core-layout.md`
|
- [x] 02 — Create main app shell with tab navigation → `02-core-layout.md`
|
||||||
- [ ] 16 — Implement tab navigation component → `16-tab-navigation.md`
|
- [x] 16 — Implement tab navigation component → `16-tab-navigation.md`
|
||||||
- [ ] 17 — Add keyboard shortcuts and navigation handling → `17-keyboard-handling.md`
|
- [x] 17 — Add keyboard shortcuts and navigation handling → `17-keyboard-handling.md`
|
||||||
|
|
||||||
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
## Phase 3: File Sync & Data Import/Export 💾
|
## Phase 3: File Sync & Data Import/Export 💾
|
||||||
**Implement direct file sync with JSON/XML formats**
|
**Implement direct file sync with JSON/XML formats**
|
||||||
|
|
||||||
- [ ] 03 — Implement direct file sync (JSON/XML import/export) → `03-file-sync.md`
|
- [x] 03 — Implement direct file sync (JSON/XML import/export) → `03-file-sync.md`
|
||||||
- [ ] 18 — Create sync data models (JSON/XML formats) → `18-sync-data-models.md`
|
- [x] 18 — Create sync data models (JSON/XML formats) → `18-sync-data-models.md`
|
||||||
- [ ] 19 — Build import/export functionality → `19-import-export.md`
|
- [x] 19 — Build import/export functionality → `19-import-export.md`
|
||||||
- [ ] 20 — Create file picker UI for import → `20-file-picker.md`
|
- [x] 20 — Create file picker UI for import → `20-file-picker.md`
|
||||||
- [ ] 21 — Build sync status indicator → `21-sync-status.md`
|
- [x] 21 — Build sync status indicator → `21-sync-status.md`
|
||||||
- [ ] 22 — Add backup/restore functionality → `22-backup-restore.md`
|
- [x] 22 — Add backup/restore functionality → `22-backup-restore.md`
|
||||||
|
|
||||||
**Dependencies:** 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
**Dependencies:** 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
## Phase 4: Authentication System 🔐
|
## Phase 4: Authentication System 🔐
|
||||||
**Implement authentication (MUST be implemented as optional for users)**
|
**Implement authentication (MUST be implemented as optional for users)**
|
||||||
|
|
||||||
- [ ] 04 — Build optional authentication system → `04-authentication.md`
|
- [x] 04 — Build optional authentication system → `04-authentication.md`
|
||||||
- [ ] 23 — Create authentication state (disabled by default) → `23-auth-state.md`
|
- [x] 23 — Create authentication state (disabled by default) → `23-auth-state.md`
|
||||||
- [ ] 24 — Build simple login screen (email/password) → `24-login-screen.md`
|
- [x] 24 — Build simple login screen (email/password) → `24-login-screen.md`
|
||||||
- [ ] 25 — Implement 8-character code validation flow → `25-code-validation.md`
|
- [x] 25 — Implement 8-character code validation flow → `25-code-validation.md`
|
||||||
- [ ] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md`
|
- [x] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md`
|
||||||
- [ ] 27 — Create sync-only user profile → `27-sync-profile.md`
|
- [x] 27 — Create sync-only user profile → `27-sync-profile.md`
|
||||||
|
|
||||||
**Dependencies:** 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
**Dependencies:** 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||||
|
|
||||||
@@ -60,12 +60,12 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
## Phase 5: Feed Management 📻
|
## Phase 5: Feed Management 📻
|
||||||
**Create feed data models and management UI**
|
**Create feed data models and management UI**
|
||||||
|
|
||||||
- [ ] 05 — Create feed data models and types → `05-feed-management.md`
|
- [x] 05 — Create feed data models and types → `05-feed-management.md`
|
||||||
- [ ] 28 — Create feed data models and types → `28-feed-types.md`
|
- [x] 28 — Create feed data models and types → `28-feed-types.md`
|
||||||
- [ ] 29 — Build feed list component (public/private feeds) → `29-feed-list.md`
|
- [x] 29 — Build feed list component (public/private feeds) → `29-feed-list.md`
|
||||||
- [ ] 30 — Implement feed source management (add/remove sources) → `30-source-management.md`
|
- [x] 30 — Implement feed source management (add/remove sources) → `30-source-management.md`
|
||||||
- [ ] 31 — Add reverse chronological ordering → `31-reverse-chronological.md`
|
- [x] 31 — Add reverse chronological ordering → `31-reverse-chronological.md`
|
||||||
- [ ] 32 — Create feed detail view → `32-feed-detail.md`
|
- [x] 32 — Create feed detail view → `32-feed-detail.md`
|
||||||
|
|
||||||
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
**Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
## Phase 13: OAuth & External Integration 🔗
|
## Phase 13: OAuth & External Integration 🔗
|
||||||
**Complete OAuth implementation and external integrations**
|
**Complete OAuth implementation and external integrations**
|
||||||
|
|
||||||
- [ ] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md`
|
- [x] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md`
|
||||||
- [ ] 67 — Implement browser redirect flow for OAuth → `67-browser-redirect.md`
|
- [ ] 67 — Implement browser redirect flow for OAuth → `67-browser-redirect.md`
|
||||||
- [ ] 68 — Build QR code display for mobile verification → `68-qr-code-display.md`
|
- [ ] 68 — Build QR code display for mobile verification → `68-qr-code-display.md`
|
||||||
|
|
||||||
|
|||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable", "ES2015", "ES2015.Promise"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "@opentui/solid",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/components/*": ["src/components/*"],
|
||||||
|
"@/stores/*": ["src/stores/*"],
|
||||||
|
"@/types/*": ["src/types/*"],
|
||||||
|
"@/utils/*": ["src/utils/*"],
|
||||||
|
"@/hooks/*": ["src/hooks/*"],
|
||||||
|
"@/api/*": ["src/api/*"],
|
||||||
|
"@/data/*": ["src/data/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user