4, partial 5
This commit is contained in:
31
build.ts
31
build.ts
@@ -1,5 +1,8 @@
|
||||
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",
|
||||
@@ -9,4 +12,32 @@ await Bun.build({
|
||||
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")
|
||||
|
||||
18
lint.ts
18
lint.ts
@@ -1,18 +0,0 @@
|
||||
const proc = Bun.spawn({
|
||||
cmd: [
|
||||
"bunx",
|
||||
"eslint",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx",
|
||||
],
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -3,10 +3,14 @@
|
||||
"module": "src/index.tsx",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"podtui": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/index.tsx",
|
||||
"dev": "bun run --watch src/index.tsx",
|
||||
"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"
|
||||
},
|
||||
|
||||
206
src/App.tsx
206
src/App.tsx
@@ -1,45 +1,199 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
}
|
||||
import { createSignal } from "solid-js"
|
||||
import { Layout } from "./components/Layout"
|
||||
import { Navigation } from "./components/Navigation"
|
||||
import { TabNavigation } from "./components/TabNavigation"
|
||||
import { KeyboardHandler } from "./components/KeyboardHandler"
|
||||
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 { useAuthStore } from "./stores/auth"
|
||||
import type { TabId } from "./components/Tab"
|
||||
import type { Feed, FeedVisibility } from "./types/feed"
|
||||
import type { AuthScreen } from "./types/auth"
|
||||
|
||||
// Mock data for demonstration
|
||||
const MOCK_FEEDS: Feed[] = [
|
||||
{
|
||||
id: "1",
|
||||
podcast: {
|
||||
id: "p1",
|
||||
title: "The Daily Tech News",
|
||||
description: "Your daily dose of technology news and insights from around the world.",
|
||||
feedUrl: "https://example.com/tech.rss",
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: [],
|
||||
visibility: "public" as FeedVisibility,
|
||||
sourceId: "rss",
|
||||
lastUpdated: new Date(),
|
||||
isPinned: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
podcast: {
|
||||
id: "p2",
|
||||
title: "Code & Coffee",
|
||||
description: "Weekly discussions about programming, software development, and coffee.",
|
||||
feedUrl: "https://example.com/code.rss",
|
||||
lastUpdated: new Date(Date.now() - 86400000),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: [],
|
||||
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.",
|
||||
feedUrl: "https://example.com/science.rss",
|
||||
lastUpdated: new Date(Date.now() - 172800000),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: [],
|
||||
visibility: "public" as FeedVisibility,
|
||||
sourceId: "itunes",
|
||||
lastUpdated: new Date(Date.now() - 172800000),
|
||||
isPinned: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const activeTab = createSignal<TabId>("discover")
|
||||
const [activeTab, setActiveTab] = createSignal<TabId>("discover")
|
||||
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login")
|
||||
const [showAuthPanel, setShowAuthPanel] = createSignal(false)
|
||||
const auth = useAuthStore()
|
||||
|
||||
const renderContent = () => {
|
||||
const tab = activeTab()
|
||||
|
||||
switch (tab) {
|
||||
case "feeds":
|
||||
return (
|
||||
<FeedList
|
||||
feeds={MOCK_FEEDS}
|
||||
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>
|
||||
<span fg="gray">Account:</span>
|
||||
</text>
|
||||
{auth.isAuthenticated ? (
|
||||
<text>
|
||||
<span fg="green">Signed in as {auth.user?.email}</span>
|
||||
</text>
|
||||
) : (
|
||||
<text>
|
||||
<span fg="yellow">Not signed in</span>
|
||||
</text>
|
||||
)}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={() => setShowAuthPanel(true)}
|
||||
>
|
||||
<text>
|
||||
<span fg="cyan">
|
||||
{auth.isAuthenticated ? "[A] Account" : "[A] Sign In"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
case "discover":
|
||||
case "search":
|
||||
case "player":
|
||||
default:
|
||||
return (
|
||||
<box border style={{ padding: 2 }}>
|
||||
<text>
|
||||
<strong>{tab}</strong>
|
||||
<br />
|
||||
<span fg="gray">Content placeholder - coming in later phases</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardHandler onTabSelect={activeTab[1]}>
|
||||
<KeyboardHandler onTabSelect={setActiveTab}>
|
||||
<Layout
|
||||
header={
|
||||
<TabNavigation
|
||||
activeTab={activeTab[0]()}
|
||||
onTabSelect={activeTab[1]}
|
||||
/>
|
||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||
}
|
||||
footer={
|
||||
<Navigation activeTab={activeTab[0]()} onTabSelect={activeTab[1]} />
|
||||
<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||
}
|
||||
>
|
||||
<box style={{ padding: 1 }}>
|
||||
{activeTab[0]() === "settings" ? (
|
||||
<SyncPanel />
|
||||
) : (
|
||||
<box border style={{ padding: 2 }}>
|
||||
<text>
|
||||
<strong>{`${activeTab[0]()}`}</strong>
|
||||
<br />
|
||||
<span>Content placeholder</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
<box style={{ padding: 1 }}>{renderContent()}</box>
|
||||
</Layout>
|
||||
</KeyboardHandler>
|
||||
)
|
||||
|
||||
202
src/components/CodeValidation.tsx
Normal file
202
src/components/CodeValidation.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 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}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<text>
|
||||
<strong>Enter Sync Code</strong>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Enter your 8-character sync code to link your account.
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
You can get this code from the web portal.
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Code display */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg={focusField() === "code" ? "cyan" : undefined}>
|
||||
Code ({codeProgress()}):
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<box border padding={1}>
|
||||
<text>
|
||||
<span
|
||||
fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}
|
||||
>
|
||||
{codeDisplay()}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Hidden input for actual typing */}
|
||||
<input
|
||||
value={code()}
|
||||
onInput={handleCodeInput}
|
||||
placeholder=""
|
||||
focused={props.focused && focusField() === "code"}
|
||||
width={30}
|
||||
/>
|
||||
|
||||
{codeError() && (
|
||||
<text>
|
||||
<span fg="red">{codeError()}</span>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "submit" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||
[Esc] Back to Login
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Auth error message */}
|
||||
{auth.error && (
|
||||
<text>
|
||||
<span fg="red">{auth.error.message}</span>
|
||||
</text>
|
||||
)}
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
177
src/components/FeedFilter.tsx
Normal file
177
src/components/FeedFilter.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Feed filter component for PodTUI
|
||||
* Toggle and filter options for feed list
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { FeedFilter, FeedVisibility, FeedSortField } 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 = "public"
|
||||
else if (current === "public") next = "private"
|
||||
else next = "all"
|
||||
props.onFilterChange({ ...props.filter, visibility: next })
|
||||
}
|
||||
|
||||
const cycleSort = () => {
|
||||
const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"]
|
||||
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}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<text>
|
||||
<strong>Filter Feeds</strong>
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
||||
{/* Visibility filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "visibility" ? "cyan" : "gray"}>
|
||||
Show:{" "}
|
||||
</span>
|
||||
<span fg={visibilityColor()}>{visibilityLabel()}</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Sort filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "sort" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "sort" ? "cyan" : "gray"}>Sort: </span>
|
||||
<span fg="white">{sortLabel()}</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Pinned filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "pinned" ? "cyan" : "gray"}>
|
||||
Pinned:{" "}
|
||||
</span>
|
||||
<span fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Search box */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg={focusField() === "search" ? "cyan" : "gray"}>Search:</span>
|
||||
</text>
|
||||
<input
|
||||
value={searchValue()}
|
||||
onInput={handleSearchInput}
|
||||
placeholder="Filter by name..."
|
||||
focused={props.focused && focusField() === "search"}
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter/Space to toggle</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
133
src/components/FeedItem.tsx
Normal file
133
src/components/FeedItem.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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>
|
||||
<span fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={visibilityColor()}>{visibilityIcon()}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={props.isSelected ? "white" : undefined}>
|
||||
{props.feed.customName || props.feed.podcast.title}
|
||||
</span>
|
||||
</text>
|
||||
{props.showEpisodeCount && (
|
||||
<text>
|
||||
<span fg="gray">({episodeCount()})</span>
|
||||
</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>
|
||||
<span fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={visibilityColor()}>{visibilityIcon()}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="yellow">{pinnedIndicator()}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={props.isSelected ? "white" : undefined}>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Details row */}
|
||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
||||
{props.showEpisodeCount && (
|
||||
<text>
|
||||
<span fg="gray">
|
||||
{episodeCount()} episodes ({unplayedCount()} new)
|
||||
</span>
|
||||
</text>
|
||||
)}
|
||||
{props.showLastUpdated && (
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Updated: {formatDate(props.feed.lastUpdated)}
|
||||
</span>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{/* Description (truncated) */}
|
||||
{props.feed.podcast.description && (
|
||||
<box paddingLeft={4} paddingTop={0}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
{props.feed.podcast.description.slice(0, 60)}
|
||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
200
src/components/FeedList.tsx
Normal file
200
src/components/FeedList.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Feed list component for PodTUI
|
||||
* Scrollable list of feeds with keyboard navigation
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { FeedItem } from "./FeedItem"
|
||||
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import { format } from "date-fns"
|
||||
|
||||
interface FeedListProps {
|
||||
feeds: Feed[]
|
||||
focused?: boolean
|
||||
compact?: boolean
|
||||
showEpisodeCount?: boolean
|
||||
showLastUpdated?: boolean
|
||||
onSelectFeed?: (feed: Feed) => void
|
||||
onOpenFeed?: (feed: Feed) => void
|
||||
}
|
||||
|
||||
export function FeedList(props: FeedListProps) {
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
})
|
||||
|
||||
/** Get filtered and sorted feeds */
|
||||
const filteredFeeds = (): Feed[] => {
|
||||
let result = [...props.feeds]
|
||||
|
||||
// Filter by visibility
|
||||
const vis = filter().visibility
|
||||
if (vis && vis !== "all") {
|
||||
result = result.filter((f) => f.visibility === vis)
|
||||
}
|
||||
|
||||
// Filter by pinned only
|
||||
if (filter().pinnedOnly) {
|
||||
result = result.filter((f) => f.isPinned)
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
const query = filter().searchQuery?.toLowerCase()
|
||||
if (query) {
|
||||
result = result.filter(
|
||||
(f) =>
|
||||
f.podcast.title.toLowerCase().includes(query) ||
|
||||
f.customName?.toLowerCase().includes(query) ||
|
||||
f.podcast.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort feeds
|
||||
const sortField = filter().sortBy
|
||||
const sortDir = filter().sortDirection === "asc" ? 1 : -1
|
||||
|
||||
result.sort((a, b) => {
|
||||
switch (sortField) {
|
||||
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
|
||||
}
|
||||
|
||||
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") {
|
||||
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))
|
||||
}
|
||||
|
||||
// Notify selection change
|
||||
const selectedFeed = feeds[selectedIndex()]
|
||||
if (selectedFeed && props.onSelectFeed) {
|
||||
props.onSelectFeed(selectedFeed)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVisibilityFilter = () => {
|
||||
setFilter((f) => {
|
||||
const current = f.visibility
|
||||
let next: FeedVisibility | "all"
|
||||
if (current === "all") next = "public"
|
||||
else if (current === "public") next = "private"
|
||||
else next = "all"
|
||||
return { ...f, visibility: next }
|
||||
})
|
||||
}
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = filter().visibility
|
||||
if (vis === "all") return "All"
|
||||
if (vis === "public") return "Public"
|
||||
return "Private"
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
{/* Header with filter */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||
<text>
|
||||
<strong>My Feeds</strong>
|
||||
<span fg="gray"> ({filteredFeeds().length} feeds)</span>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box border padding={0} onMouseDown={toggleVisibilityFilter}>
|
||||
<text>
|
||||
<span fg="cyan">[F] {visibilityLabel()}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Feed list in scrollbox */}
|
||||
<Show
|
||||
when={filteredFeeds().length > 0}
|
||||
fallback={
|
||||
<box border padding={2}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox
|
||||
height={15}
|
||||
focused={props.focused}
|
||||
selectedIndex={selectedIndex()}
|
||||
>
|
||||
<For each={filteredFeeds()}>
|
||||
{(feed, index) => (
|
||||
<FeedItem
|
||||
feed={feed}
|
||||
isSelected={index() === selectedIndex()}
|
||||
compact={props.compact}
|
||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||
showLastUpdated={props.showLastUpdated ?? true}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
||||
{/* Navigation help */}
|
||||
<box paddingTop={1}>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
j/k or arrows to navigate, Enter to open, F to filter
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
203
src/components/LoginScreen.tsx
Normal file
203
src/components/LoginScreen.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<text>
|
||||
<strong>Sign In</strong>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Email field */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg={focusField() === "email" ? "cyan" : undefined}>
|
||||
Email:
|
||||
</span>
|
||||
</text>
|
||||
<input
|
||||
value={email()}
|
||||
onInput={setEmail}
|
||||
placeholder="your@email.com"
|
||||
focused={props.focused && focusField() === "email"}
|
||||
width={30}
|
||||
/>
|
||||
{emailError() && (
|
||||
<text>
|
||||
<span fg="red">{emailError()}</span>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{/* Password field */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg={focusField() === "password" ? "cyan" : undefined}>
|
||||
Password:
|
||||
</span>
|
||||
</text>
|
||||
<input
|
||||
value={password()}
|
||||
onInput={setPassword}
|
||||
placeholder="********"
|
||||
focused={props.focused && focusField() === "password"}
|
||||
width={30}
|
||||
/>
|
||||
{passwordError() && (
|
||||
<text>
|
||||
<span fg="red">{passwordError()}</span>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Submit button */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "submit" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "submit" ? "cyan" : undefined}>
|
||||
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Auth error message */}
|
||||
{auth.error && (
|
||||
<text>
|
||||
<span fg="red">{auth.error.message}</span>
|
||||
</text>
|
||||
)}
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Alternative auth options */}
|
||||
<text>
|
||||
<span fg="gray">Or authenticate with:</span>
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "code" ? "yellow" : "gray"}>
|
||||
[C] Sync Code
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "oauth" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "oauth" ? "yellow" : "gray"}>
|
||||
[O] OAuth Info
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
145
src/components/OAuthPlaceholder.tsx
Normal file
145
src/components/OAuthPlaceholder.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<text>
|
||||
<strong>OAuth Authentication</strong>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* OAuth providers list */}
|
||||
<text>
|
||||
<span fg="cyan">Available OAuth Providers:</span>
|
||||
</text>
|
||||
|
||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||
{OAUTH_PROVIDERS.map((provider) => (
|
||||
<text>
|
||||
<span fg={provider.enabled ? "green" : "gray"}>
|
||||
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
||||
</span>
|
||||
<span fg="gray"> - {provider.description}</span>
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Limitation message */}
|
||||
<box border padding={1} borderColor="yellow">
|
||||
<text>
|
||||
<span fg="yellow">Terminal Limitations</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box paddingLeft={1}>
|
||||
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
||||
<text>
|
||||
<span fg="gray">{line}</span>
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Alternative options */}
|
||||
<text>
|
||||
<span fg="cyan">Recommended Alternatives:</span>
|
||||
</text>
|
||||
|
||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||
<text>
|
||||
<span fg="green">[1]</span>
|
||||
<span fg="white"> Use a sync code from the web portal</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="green">[2]</span>
|
||||
<span fg="white"> Use email/password authentication</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="green">[3]</span>
|
||||
<span fg="white"> Use file-based sync (no account needed)</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "code" ? "cyan" : undefined}>
|
||||
[C] Enter Sync Code
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||
[Esc] Back to Login
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
186
src/components/SyncProfile.tsx
Normal file
186
src/components/SyncProfile.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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}
|
||||
onKeyPress={props.focused ? handleKeyPress : undefined}
|
||||
>
|
||||
<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>
|
||||
<span fg="cyan">{userInitials()}</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* User details */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg="white">{user()?.name || "Guest User"}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">{user()?.email || "No email"}</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="gray">
|
||||
Joined: {formatDate(user()?.createdAt)}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Sync status section */}
|
||||
<box border padding={1} flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<span fg="cyan">Sync Status</span>
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">Status:</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg={user()?.syncEnabled ? "green" : "yellow"}>
|
||||
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">Last Sync:</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="white">{formatDate(lastSyncTime())}</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<span fg="gray">Method:</span>
|
||||
</text>
|
||||
<text>
|
||||
<span fg="white">File-based (JSON/XML)</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "sync" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "sync" ? "cyan" : undefined}>
|
||||
[S] Manage Sync
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "export" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "export" ? "cyan" : undefined}>
|
||||
[E] Export Data
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "logout" ? "#333" : undefined}
|
||||
>
|
||||
<text>
|
||||
<span fg={focusField() === "logout" ? "red" : "gray"}>
|
||||
[L] Logout
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text>
|
||||
<span fg="gray">Tab to navigate, Enter to select</span>
|
||||
</text>
|
||||
</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()
|
||||
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
|
||||
}
|
||||
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"
|
||||
103
src/types/source.ts
Normal file
103
src/types/source.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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
|
||||
/** 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
|
||||
/** 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",
|
||||
},
|
||||
{
|
||||
id: "rss",
|
||||
name: "RSS Feed",
|
||||
type: SourceType.RSS,
|
||||
baseUrl: "",
|
||||
enabled: true,
|
||||
description: "Add podcasts via RSS feed URL",
|
||||
},
|
||||
]
|
||||
@@ -9,10 +9,10 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
## Phase 1: Project Foundation 🏗️
|
||||
**Setup and configure the development environment**
|
||||
|
||||
- [ ] 01 — Initialize SolidJS OpenTUI project with Bun → `01-project-setup.md`
|
||||
- [ ] 13 — Set up TypeScript configuration and build system → `13-typescript-config.md`
|
||||
- [ ] 14 — Create project directory structure and dependencies → `14-project-structure.md`
|
||||
- [ ] 15 — Build responsive layout system (Flexbox) → `15-responsive-layout.md`
|
||||
- [x] 01 — Initialize SolidJS OpenTUI project with Bun → `01-project-setup.md`
|
||||
- [x] 13 — Set up TypeScript configuration and build system → `13-typescript-config.md`
|
||||
- [x] 14 — Create project directory structure and dependencies → `14-project-structure.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
|
||||
|
||||
@@ -21,9 +21,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
## Phase 2: Core Architecture 🏗️
|
||||
**Build the main application shell and navigation**
|
||||
|
||||
- [ ] 02 — Create main app shell with tab navigation → `02-core-layout.md`
|
||||
- [ ] 16 — Implement tab navigation component → `16-tab-navigation.md`
|
||||
- [ ] 17 — Add keyboard shortcuts and navigation handling → `17-keyboard-handling.md`
|
||||
- [x] 02 — Create main app shell with tab navigation → `02-core-layout.md`
|
||||
- [x] 16 — Implement tab navigation component → `16-tab-navigation.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
|
||||
|
||||
@@ -32,12 +32,12 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
## Phase 3: File Sync & Data Import/Export 💾
|
||||
**Implement direct file sync with JSON/XML formats**
|
||||
|
||||
- [ ] 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`
|
||||
- [ ] 19 — Build import/export functionality → `19-import-export.md`
|
||||
- [ ] 20 — Create file picker UI for import → `20-file-picker.md`
|
||||
- [ ] 21 — Build sync status indicator → `21-sync-status.md`
|
||||
- [ ] 22 — Add backup/restore functionality → `22-backup-restore.md`
|
||||
- [x] 03 — Implement direct file sync (JSON/XML import/export) → `03-file-sync.md`
|
||||
- [x] 18 — Create sync data models (JSON/XML formats) → `18-sync-data-models.md`
|
||||
- [x] 19 — Build import/export functionality → `19-import-export.md`
|
||||
- [x] 20 — Create file picker UI for import → `20-file-picker.md`
|
||||
- [x] 21 — Build sync status indicator → `21-sync-status.md`
|
||||
- [x] 22 — Add backup/restore functionality → `22-backup-restore.md`
|
||||
|
||||
**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 🔐
|
||||
**Implement authentication (MUST be implemented as optional for users)**
|
||||
|
||||
- [ ] 04 — Build optional authentication system → `04-authentication.md`
|
||||
- [ ] 23 — Create authentication state (disabled by default) → `23-auth-state.md`
|
||||
- [ ] 24 — Build simple login screen (email/password) → `24-login-screen.md`
|
||||
- [ ] 25 — Implement 8-character code validation flow → `25-code-validation.md`
|
||||
- [ ] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md`
|
||||
- [ ] 27 — Create sync-only user profile → `27-sync-profile.md`
|
||||
- [x] 04 — Build optional authentication system → `04-authentication.md`
|
||||
- [x] 23 — Create authentication state (disabled by default) → `23-auth-state.md`
|
||||
- [x] 24 — Build simple login screen (email/password) → `24-login-screen.md`
|
||||
- [x] 25 — Implement 8-character code validation flow → `25-code-validation.md`
|
||||
- [x] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md`
|
||||
- [x] 27 — Create sync-only user profile → `27-sync-profile.md`
|
||||
|
||||
**Dependencies:** 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||
|
||||
@@ -60,9 +60,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
## Phase 5: Feed Management 📻
|
||||
**Create feed data models and management UI**
|
||||
|
||||
- [ ] 05 — Create feed data models and types → `05-feed-management.md`
|
||||
- [ ] 28 — Create feed data models and types → `28-feed-types.md`
|
||||
- [ ] 29 — Build feed list component (public/private feeds) → `29-feed-list.md`
|
||||
- [x] 05 — Create feed data models and types → `05-feed-management.md`
|
||||
- [x] 28 — Create feed data models and types → `28-feed-types.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`
|
||||
- [ ] 31 — Add reverse chronological ordering → `31-reverse-chronological.md`
|
||||
- [ ] 32 — Create feed detail view → `32-feed-detail.md`
|
||||
@@ -170,7 +170,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
## Phase 13: OAuth & External Integration 🔗
|
||||
**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`
|
||||
- [ ] 68 — Build QR code display for mobile verification → `68-qr-code-display.md`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user