feat: add tui app

This commit is contained in:
Max Koon
2025-11-14 13:26:15 -05:00
parent 058f2bb94f
commit 5b14b4e7a4
22 changed files with 2395 additions and 128 deletions

View File

@@ -1,130 +1,14 @@
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { authClient } from '@/lib/auth-client';
import { Button, Linking, Platform, Pressable, Text, View } from 'react-native';
import { useQuery, useZero } from "@rocicorp/zero/react";
import { queries, type Mutators, type Schema } from '@money/shared';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { useEffect, useState, type ReactNode } from 'react';
import { Settings } from "@money/ui";
export default function HomeScreen() { export default function HomeScreen() {
const { data: session } = authClient.useSession();
const onLogout = () => {
authClient.signOut();
}
const z = useZero<Schema, Mutators>();
const [plaidLink] = useQuery(queries.getPlaidLink(session));
const [items] = useQuery(queries.getItems(session));
return ( return (
<SafeAreaView> <SafeAreaView>
<Header /> <Header />
<Settings />
<UI
columns={[
{
name: "Banks",
items: items,
renderItem: (item, props) => <Row {...props}>{item.name}</Row>
},
{
name: "Family",
items: [],
renderItem() {
return <View></View>;
},
}
]}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
type Col<T> = {
name: string;
items: T[];
renderItem: (item: T, props: { isSelected: boolean, isActive: boolean }) => ReactNode;
}
type State = {
idx: number;
columns: Map<number, State>;
};
function UI({ columns }: { columns: Col<any>[] }) {
const [col, setCol] = useState(0);
const [state, setState] = useState<State>({
idx: 0,
columns: new Map(
Array.from({ length: columns.length })
.map((_, i) => ([i, { idx: 0, columns: new Map() }]))
)
});
const getColState = (res: State): State => {
let i = col;
while (i > 0) {
res = res.columns.get(res.idx)!;
i--;
}
return res;
}
const colState = getColState(state);
const curr = columns.at(col)!;
useEffect(() => {
if (Platform.OS != 'web') return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "j") {
setState((prev) => {
if (prev.idx + 1 == colState.columns.size) return prev;
return {...prev, ...{ idx: prev.idx + 1 }};
});
} else if (event.key === "k") {
setState((prev) => {
if (prev.idx == 0) return prev;
return {...prev, ...{ idx: prev.idx - 1 }};
});
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<View style={{ flexDirection: "row" }}>
<Column>
{columns.map((c, i) => <Pressable onPress={() => { setCol(i) }}><Row isSelected={col == i} isActive={col == 0}>{c.name}</Row></Pressable>)}
</Column>
<Column>
{curr.items.map((item, i) => curr.renderItem(item, { isSelected: colState.idx == i, isActive: col == 1 }))}
</Column>
</View>
);
}
function Column({ children }: { children: ReactNode }) {
return (
<View>
{children}
</View>
);
}
function Row({ children, isSelected, isActive }: { children: ReactNode, isSelected: boolean, isActive: boolean }) {
const color = isSelected ? 'white': undefined;
const backgroundColor = isSelected ? (isActive ? 'black' : 'gray'): undefined;
return (
<View>
<Text style={{ fontFamily: 'mono', color, backgroundColor }}>{children}</Text>
</View>
);
}

View File

@@ -17,6 +17,7 @@
"@better-auth/expo": "^1.3.27", "@better-auth/expo": "^1.3.27",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@money/shared": "workspace:*", "@money/shared": "workspace:*",
"@money/ui": "workspace:*",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",

34
apps/tui/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
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
apps/tui/README.md Normal file
View File

@@ -0,0 +1,15 @@
# react
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run src/index.tsx
```
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.

41
apps/tui/build.ts Normal file
View File

@@ -0,0 +1,41 @@
import esbuild from "esbuild";
import path from "path";
// Custom plugin to alias "react-native" to react-native-opentui
const aliasPlugin = {
name: "alias-react-native",
setup(build) {
build.onResolve({ filter: /^react-native$/ }, args => {
return {
path: path.resolve(__dirname, "../../packages/react-native-opentui/index.tsx"),
};
});
},
};
// Build configuration
await esbuild.build({
entryPoints: ["src/index.tsx"], // your app entry
bundle: true, // inline all dependencies (ui included)
platform: "node", // Node/Bun target
format: "esm", // keep ESM for top-level await
outfile: "dist/index.js",
sourcemap: true,
plugins: [aliasPlugin],
loader: {
".ts": "ts",
".tsx": "tsx",
},
external: [
// leave OpenTUI and Bun built-ins for Bun runtime
"react",
"@opentui/core",
"@opentui/react",
"@opentui/react/jsx-runtime",
"bun:ffi",
// "./assets/**/*.scm",
// "./assets/**/*.wasm",
],
});
console.log("✅ App bundled successfully");

22
apps/tui/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@money/tui",
"version": "0.0.1",
"scripts": {
"build": "bun run build.js",
"start": "bun run dist/index.js"
},
"peerDependencies": {
"react": "*"
},
"dependencies": {
"@money/ui": "workspace:*",
"@money/shared": "workspace:*",
"@opentui/core": "^0.1.39",
"@opentui/react": "^0.1.39",
"react-native": "^0.82.1",
"react-native-opentui": "workspace:*"
},
"devDependencies": {
"esbuild": "^0.27.0"
}
}

20
apps/tui/src/index.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { RGBA, TextAttributes, createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { Settings } from "@money/ui";
import { ZeroProvider } from "@rocicorp/zero/react";
import { schema } from '@money/shared';
const userID = "anon";
const server = "http://laptop:4848";
const auth = undefined;
function Main() {
return (
<ZeroProvider {...{ userID, auth, server, schema }}>
<Settings />
</ZeroProvider>
);
}
const renderer = await createCliRenderer();
createRoot(renderer).render(<Main />);

33
apps/tui/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"paths": {
"react-native": ["../react-native-opentui"]
},
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -19,6 +19,7 @@
packages = with pkgs; [ packages = with pkgs; [
corepack corepack
nodejs_22 nodejs_22
bun
postgresql postgresql
process-compose process-compose

View File

@@ -2,7 +2,8 @@
"name": "money", "name": "money",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "process-compose up -p 0" "dev": "process-compose up -p 0",
"tui": "bun run --hot apps/tui/src/index.tsx"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import type { ViewProps, TextProps } from "react-native";
export function View({ children, style }: ViewProps) {
const bg = style &&
'backgroundColor' in style
? typeof style.backgroundColor == 'string'
? style.backgroundColor
: undefined
: undefined;
return <box backgroundColor={bg}>{children}</box>
}
export function Text({ style, children }: TextProps) {
const fg = style &&
'color' in style
? typeof style.color == 'string'
? style.color
: undefined
: undefined;
return <text fg={fg || "black"}>{children}</text>
}
export const Platform = {
OS: "tui",
};
export default {
View,
Text,
}

View File

@@ -0,0 +1,11 @@
{
"name": "react-native-opentui",
"version": "1.0.0",
"main": "index.tsx",
"exports": {
".": "./index.tsx"
},
"peerDependencies": {
"react": "*"
}
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true
}
}

View File

@@ -32,9 +32,9 @@ export const queries = {
.orderBy('name', 'asc'); .orderBy('name', 'asc');
}), }),
getItems: syncedQueryWithContext('getItems', z.tuple([]), (authData: AuthData | null) => { getItems: syncedQueryWithContext('getItems', z.tuple([]), (authData: AuthData | null) => {
isLoggedIn(authData); // isLoggedIn(authData);
return builder.plaidAccessTokens return builder.plaidAccessTokens
.where('userId', '=', authData.user.id) // .where('userId', '=', authData.user.id)
.orderBy('createdAt', 'desc'); .orderBy('createdAt', 'desc');
}) })
}; };

14
packages/ui/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@money/ui",
"private": true,
"version": "1.0.0",
"description": "",
"exports": {
".": "./src/index.tsx"
},
"dependencies": {
"react-native-opentui": "workspace:*",
"@money/shared": "workspace:*"
},
"packageManager": "pnpm@10.18.2"
}

15
packages/ui/src/index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Text } from "react-native";
import { List } from "./list";
import { useQuery } from "@rocicorp/zero/react";
import { queries } from '@money/shared';
export function Settings() {
const [items] = useQuery(queries.getItems(null));
return <List
items={items}
renderItem={({ item, isSelected }) => <Text style={{ color: isSelected ? 'white' : 'black' }}>{item.name}</Text>}
/>
}

30
packages/ui/src/list.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { useState, type ReactNode } from "react";
import { View, Text } from "react-native";
import { useKeyboard } from "./useKeyboard";
export type ListProps<T> = {
items: T[],
renderItem: (props: { item: T, isSelected: boolean }) => ReactNode;
};
export function List<T>({ items, renderItem }: ListProps<T>) {
const [idx, setIdx] = useState(0);
useKeyboard((key) => {
if (key.name == 'j') {
setIdx((prevIdx) => prevIdx + 1 < items.length ? prevIdx + 1 : items.length - 1);
} else if (key.name == 'k') {
setIdx((prevIdx) => prevIdx == 0 ? 0 : prevIdx - 1);
} else if (key.name == 'g' && key.shift) {
setIdx(items.length - 1);
}
}, [items]);
return (
<View>
{items.map((item, index) => <View style={{ backgroundColor: index == idx ? 'black' : undefined }}>
{renderItem({ item, isSelected: index == idx })}
</View>)}
</View>
);
}

View File

@@ -0,0 +1,5 @@
import { useKeyboard as useOpentuiKeyboard } from "@opentui/react";
export function useKeyboard(handler: Parameters<typeof useOpentuiKeyboard>[0], _deps: any[] = []) {
return useOpentuiKeyboard(handler);
}

View File

@@ -0,0 +1,35 @@
import { useEffect } from "react";
import type { KeyboardEvent } from "react";
import type { KeyEvent } from "@opentui/core";
export function useKeyboard(handler: (key: KeyEvent) => void, deps: any[] = []) {
useEffect(() => {
const handlerWeb = (event: KeyboardEvent) => {
// @ts-ignore
handler({
name: event.key.toLowerCase(),
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
option: event.metaKey,
sequence: '',
number: false,
raw: '',
eventType: 'press',
source: "raw",
code: event.code,
super: false,
hyper: false,
capsLock: false,
numLock: false,
baseCode: event.keyCode,
});
};
// @ts-ignore
window.addEventListener("keydown", handlerWeb);
// @ts-ignore
return () => window.removeEventListener("keydown", handlerWeb);
}, deps);
}

28
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

2042
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -55,7 +55,7 @@ processes:
studio: studio:
command: npx drizzle-kit studio command: npx drizzle-kit studio
working_dir: ./shared working_dir: ./packages/shared
depends_on: depends_on:
db: db:
condition: process_healthy condition: process_healthy