feat: add tui app
This commit is contained in:
@@ -1,130 +1,14 @@
|
||||
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 { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import { Settings } from "@money/ui";
|
||||
|
||||
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 (
|
||||
<SafeAreaView>
|
||||
<Header />
|
||||
|
||||
<UI
|
||||
columns={[
|
||||
{
|
||||
name: "Banks",
|
||||
items: items,
|
||||
renderItem: (item, props) => <Row {...props}>{item.name}</Row>
|
||||
},
|
||||
{
|
||||
name: "Family",
|
||||
items: [],
|
||||
renderItem() {
|
||||
return <View></View>;
|
||||
},
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<Settings />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@better-auth/expo": "^1.3.27",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@money/shared": "workspace:*",
|
||||
"@money/ui": "workspace:*",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
|
||||
34
apps/tui/.gitignore
vendored
Normal file
34
apps/tui/.gitignore
vendored
Normal 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
15
apps/tui/README.md
Normal 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
41
apps/tui/build.ts
Normal 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
22
apps/tui/package.json
Normal 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
20
apps/tui/src/index.tsx
Normal 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
33
apps/tui/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
packages = with pkgs; [
|
||||
corepack
|
||||
nodejs_22
|
||||
bun
|
||||
|
||||
postgresql
|
||||
process-compose
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "money",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "process-compose up -p 0"
|
||||
"dev": "process-compose up -p 0",
|
||||
"tui": "bun run --hot apps/tui/src/index.tsx"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
33
packages/react-native-opentui/index.tsx
Normal file
33
packages/react-native-opentui/index.tsx
Normal 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,
|
||||
}
|
||||
11
packages/react-native-opentui/package.json
Normal file
11
packages/react-native-opentui/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "react-native-opentui",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"exports": {
|
||||
".": "./index.tsx"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
}
|
||||
12
packages/react-native-opentui/tsconfig.json
Normal file
12
packages/react-native-opentui/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,9 @@ export const queries = {
|
||||
.orderBy('name', 'asc');
|
||||
}),
|
||||
getItems: syncedQueryWithContext('getItems', z.tuple([]), (authData: AuthData | null) => {
|
||||
isLoggedIn(authData);
|
||||
// isLoggedIn(authData);
|
||||
return builder.plaidAccessTokens
|
||||
.where('userId', '=', authData.user.id)
|
||||
// .where('userId', '=', authData.user.id)
|
||||
.orderBy('createdAt', 'desc');
|
||||
})
|
||||
};
|
||||
|
||||
14
packages/ui/package.json
Normal file
14
packages/ui/package.json
Normal 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
15
packages/ui/src/index.tsx
Normal 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
30
packages/ui/src/list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
5
packages/ui/src/useKeyboard.ts
Normal file
5
packages/ui/src/useKeyboard.ts
Normal 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);
|
||||
}
|
||||
35
packages/ui/src/useKeyboard.web.ts
Normal file
35
packages/ui/src/useKeyboard.web.ts
Normal 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
28
packages/ui/tsconfig.json
Normal 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
2042
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,7 @@ processes:
|
||||
|
||||
studio:
|
||||
command: npx drizzle-kit studio
|
||||
working_dir: ./shared
|
||||
working_dir: ./packages/shared
|
||||
depends_on:
|
||||
db:
|
||||
condition: process_healthy
|
||||
|
||||
Reference in New Issue
Block a user