start
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user