This commit is contained in:
2026-02-04 00:06:16 -05:00
parent f08afb2ed1
commit 7b5c256e07
38 changed files with 933 additions and 1 deletions

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}