start
This commit is contained in:
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.
|
||||||
12
build.ts
Normal file
12
build.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||||
|
|
||||||
|
await Bun.build({
|
||||||
|
entrypoints: ["./src/index.tsx"],
|
||||||
|
outdir: "./dist",
|
||||||
|
target: "bun",
|
||||||
|
minify: true,
|
||||||
|
sourcemap: "external",
|
||||||
|
plugins: [solidPlugin],
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Build complete")
|
||||||
1
bunfig.toml
Normal file
1
bunfig.toml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
preload = ["@opentui/solid/preload"]
|
||||||
18
lint.ts
Normal file
18
lint.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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 {}
|
||||||
34
package.json
34
package.json
@@ -1 +1,33 @@
|
|||||||
{ "dependencies": { "@opentui/core": "^0.1.77" } }
|
{
|
||||||
|
"name": "podcast-tui-app",
|
||||||
|
"module": "src/index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/index.tsx",
|
||||||
|
"dev": "bun run --watch src/index.tsx",
|
||||||
|
"build": "bun run build.ts",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
46
src/App.tsx
Normal file
46
src/App.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
|
let current = value
|
||||||
|
return [() => current, (next) => {
|
||||||
|
current = next
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
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 type { TabId } from "./components/Tab"
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const activeTab = createSignal<TabId>("discover")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardHandler onTabSelect={activeTab[1]}>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<TabNavigation
|
||||||
|
activeTab={activeTab[0]()}
|
||||||
|
onTabSelect={activeTab[1]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<Navigation activeTab={activeTab[0]()} onTabSelect={activeTab[1]} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Layout>
|
||||||
|
</KeyboardHandler>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/components/KeyboardHandler.tsx
Normal file
21
src/components/KeyboardHandler.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"
|
||||||
|
import type { TabId } from "./Tab"
|
||||||
|
|
||||||
|
type KeyboardHandlerProps = {
|
||||||
|
children?: JSX.Element
|
||||||
|
onTabSelect: (tab: TabId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardHandler(props: KeyboardHandlerProps) {
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onTabNext: () => {
|
||||||
|
props.onTabSelect("discover")
|
||||||
|
},
|
||||||
|
onTabPrev: () => {
|
||||||
|
props.onTabSelect("settings")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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())}</>
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/TabNavigation.tsx
Normal file
36
src/components/TabNavigation.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"
|
||||||
|
import { Tab, type TabId } from "./Tab"
|
||||||
|
|
||||||
|
type TabNavigationProps = {
|
||||||
|
activeTab: TabId
|
||||||
|
onTabSelect: (tab: TabId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabNavigation(props: TabNavigationProps) {
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onTabNext: () => {
|
||||||
|
if (props.activeTab === "discover") props.onTabSelect("feeds")
|
||||||
|
else if (props.activeTab === "feeds") props.onTabSelect("search")
|
||||||
|
else if (props.activeTab === "search") props.onTabSelect("player")
|
||||||
|
else if (props.activeTab === "player") props.onTabSelect("settings")
|
||||||
|
else props.onTabSelect("discover")
|
||||||
|
},
|
||||||
|
onTabPrev: () => {
|
||||||
|
if (props.activeTab === "discover") props.onTabSelect("settings")
|
||||||
|
else if (props.activeTab === "settings") props.onTabSelect("player")
|
||||||
|
else if (props.activeTab === "player") props.onTabSelect("search")
|
||||||
|
else if (props.activeTab === "search") props.onTabSelect("feeds")
|
||||||
|
else props.onTabSelect("discover")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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]
|
||||||
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 {}
|
||||||
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"
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
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