refactor: better shortcut hook
This commit is contained in:
@@ -74,6 +74,11 @@ export function View({ children, style }: ViewProps) {
|
|||||||
justifyContent: attr(style, "justifyContent", "string"),
|
justifyContent: attr(style, "justifyContent", "string"),
|
||||||
flexShrink: attr(style, "flexShrink", "number"),
|
flexShrink: attr(style, "flexShrink", "number"),
|
||||||
flexDirection: attr(style, "flexDirection", "string"),
|
flexDirection: attr(style, "flexDirection", "string"),
|
||||||
|
top: attr(style, "top", "number"),
|
||||||
|
zIndex: attr(style, "zIndex", "number"),
|
||||||
|
left: attr(style, "left", "number"),
|
||||||
|
right: attr(style, "right", "number"),
|
||||||
|
bottom: attr(style, "bottom", "number"),
|
||||||
flexGrow:
|
flexGrow:
|
||||||
attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
|
attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
numeric,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const users = pgTable(
|
export const users = pgTable(
|
||||||
@@ -65,3 +66,25 @@ export const plaidAccessTokens = pgTable("plaidAccessToken", {
|
|||||||
token: text("token").notNull(),
|
token: text("token").notNull(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const budget = pgTable("budget", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
orgId: text("org_id").notNull(),
|
||||||
|
label: text("label").notNull(),
|
||||||
|
createdBy: text("created_by").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const category = pgTable("category", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
budgetId: text("budget_id").notNull(),
|
||||||
|
amount: decimal("amount").notNull(),
|
||||||
|
every: text("every", { enum: ["year", "month", "week"] }).notNull(),
|
||||||
|
order: numeric("order").notNull(),
|
||||||
|
label: text("label").notNull(),
|
||||||
|
color: text("color").notNull(),
|
||||||
|
createdBy: text("created_by").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Transaction } from "@rocicorp/zero";
|
import type { Transaction } from "@rocicorp/zero";
|
||||||
import type { AuthData } from "./auth";
|
import { authDataSchema, type AuthData } from "./auth";
|
||||||
import { type Schema } from "./zero-schema.gen";
|
import { type Schema } from "./zero-schema.gen";
|
||||||
import { isLoggedIn } from "./zql";
|
import { isLoggedIn } from "./zql";
|
||||||
|
|
||||||
@@ -39,6 +39,17 @@ export function createMutators(authData: AuthData | null) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
budget: {
|
||||||
|
async create(tx: Tx, { id }: { id: string }) {
|
||||||
|
isLoggedIn(authData);
|
||||||
|
await tx.mutate.budget.insert({
|
||||||
|
id,
|
||||||
|
orgId: authData.user.id,
|
||||||
|
label: "New Budget",
|
||||||
|
createdBy: authData.user.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,4 +60,20 @@ export const queries = {
|
|||||||
.orderBy("createdAt", "desc");
|
.orderBy("createdAt", "desc");
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
getBudgets: syncedQueryWithContext(
|
||||||
|
"getBudgets",
|
||||||
|
z.tuple([]),
|
||||||
|
(authData: AuthData | null) => {
|
||||||
|
isLoggedIn(authData);
|
||||||
|
return builder.budget.limit(10);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
getBudgetCategories: syncedQueryWithContext(
|
||||||
|
"getBudgetCategories",
|
||||||
|
z.tuple([]),
|
||||||
|
(authData: AuthData | null) => {
|
||||||
|
isLoggedIn(authData);
|
||||||
|
return builder.category.orderBy("order", "desc");
|
||||||
|
},
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,6 +112,170 @@ export const schema = {
|
|||||||
},
|
},
|
||||||
primaryKey: ["id"],
|
primaryKey: ["id"],
|
||||||
},
|
},
|
||||||
|
budget: {
|
||||||
|
name: "budget",
|
||||||
|
columns: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"budget",
|
||||||
|
"id"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
orgId: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"budget",
|
||||||
|
"orgId"
|
||||||
|
>,
|
||||||
|
serverName: "org_id",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"budget",
|
||||||
|
"label"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"budget",
|
||||||
|
"createdBy"
|
||||||
|
>,
|
||||||
|
serverName: "created_by",
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"budget",
|
||||||
|
"createdAt"
|
||||||
|
>,
|
||||||
|
serverName: "created_at",
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"budget",
|
||||||
|
"updatedAt"
|
||||||
|
>,
|
||||||
|
serverName: "updated_at",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primaryKey: ["id"],
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
name: "category",
|
||||||
|
columns: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"id"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
budgetId: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"budgetId"
|
||||||
|
>,
|
||||||
|
serverName: "budget_id",
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
type: "number",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"amount"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
every: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"every"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
type: "number",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"order"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"label"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"color"
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: "string",
|
||||||
|
optional: false,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"createdBy"
|
||||||
|
>,
|
||||||
|
serverName: "created_by",
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"createdAt"
|
||||||
|
>,
|
||||||
|
serverName: "created_at",
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
customType: null as unknown as ZeroCustomType<
|
||||||
|
ZeroSchema,
|
||||||
|
"category",
|
||||||
|
"updatedAt"
|
||||||
|
>,
|
||||||
|
serverName: "updated_at",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primaryKey: ["id"],
|
||||||
|
},
|
||||||
plaidAccessTokens: {
|
plaidAccessTokens: {
|
||||||
name: "plaidAccessTokens",
|
name: "plaidAccessTokens",
|
||||||
columns: {
|
columns: {
|
||||||
@@ -433,6 +597,16 @@ export type Schema = typeof schema;
|
|||||||
* This type is auto-generated from your Drizzle schema definition.
|
* This type is auto-generated from your Drizzle schema definition.
|
||||||
*/
|
*/
|
||||||
export type Balance = Row<Schema["tables"]["balance"]>;
|
export type Balance = Row<Schema["tables"]["balance"]>;
|
||||||
|
/**
|
||||||
|
* Represents a row from the "budget" table.
|
||||||
|
* This type is auto-generated from your Drizzle schema definition.
|
||||||
|
*/
|
||||||
|
export type Budget = Row<Schema["tables"]["budget"]>;
|
||||||
|
/**
|
||||||
|
* Represents a row from the "category" table.
|
||||||
|
* This type is auto-generated from your Drizzle schema definition.
|
||||||
|
*/
|
||||||
|
export type Category = Row<Schema["tables"]["category"]>;
|
||||||
/**
|
/**
|
||||||
* Represents a row from the "plaidAccessTokens" table.
|
* Represents a row from the "plaidAccessTokens" table.
|
||||||
* This type is auto-generated from your Drizzle schema definition.
|
* This type is auto-generated from your Drizzle schema definition.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useKeyboard } from "../src/useKeyboard";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Text, Pressable } from "react-native";
|
import { Text, Pressable } from "react-native";
|
||||||
|
|
||||||
|
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
||||||
|
|
||||||
export interface ButtonProps {
|
export interface ButtonProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -21,10 +22,16 @@ const STYLES: Record<
|
|||||||
export function Button({ children, variant, onPress, shortcut }: ButtonProps) {
|
export function Button({ children, variant, onPress, shortcut }: ButtonProps) {
|
||||||
const { backgroundColor, color } = STYLES[variant || "default"];
|
const { backgroundColor, color } = STYLES[variant || "default"];
|
||||||
|
|
||||||
useKeyboard((key) => {
|
// if (shortcut) {
|
||||||
if (!shortcut || !onPress) return;
|
// useKeys((key) => {
|
||||||
if (key.name == shortcut) onPress();
|
// if (
|
||||||
});
|
// typeof shortcut == "object"
|
||||||
|
// ? key.name == shortcut.name
|
||||||
|
// : key.name == shortcut
|
||||||
|
// )
|
||||||
|
// return onPress;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} style={{ backgroundColor }}>
|
<Pressable onPress={onPress} style={{ backgroundColor }}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createContext, type ReactNode } from "react";
|
import { createContext, type ReactNode } from "react";
|
||||||
import { Modal, View, Text } from "react-native";
|
import { Modal, View, Text } from "react-native";
|
||||||
import { useKeyboard } from "../src/useKeyboard";
|
|
||||||
|
|
||||||
export interface DialogState {
|
export interface DialogState {
|
||||||
close?: () => void;
|
close?: () => void;
|
||||||
@@ -15,12 +14,6 @@ interface ProviderProps {
|
|||||||
close?: () => void;
|
close?: () => void;
|
||||||
}
|
}
|
||||||
export function Provider({ children, visible, close }: ProviderProps) {
|
export function Provider({ children, visible, close }: ProviderProps) {
|
||||||
useKeyboard((key) => {
|
|
||||||
if (key.name == "escape") {
|
|
||||||
if (close) close();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={{ close }}>
|
<Context.Provider value={{ close }}>
|
||||||
<Modal transparent visible={visible}>
|
<Modal transparent visible={visible}>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { createContext, use, useState, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
use,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { useKeyboard } from "../src/useKeyboard";
|
|
||||||
import type { KeyEvent } from "@opentui/core";
|
import type { KeyEvent } from "@opentui/core";
|
||||||
|
import { useShortcut } from "../lib/shortcuts/hooks";
|
||||||
|
|
||||||
const HEADER_COLOR = "#7158e2";
|
const HEADER_COLOR = "#7158e2";
|
||||||
const TABLE_COLORS = ["#ddd", "#eee"];
|
const TABLE_COLORS = ["#ddd", "#eee"];
|
||||||
@@ -58,33 +65,12 @@ export function Provider<T extends ValidRecord>({
|
|||||||
const [idx, setIdx] = useState(0);
|
const [idx, setIdx] = useState(0);
|
||||||
const [selectedFrom, setSelectedFrom] = useState<number>();
|
const [selectedFrom, setSelectedFrom] = useState<number>();
|
||||||
|
|
||||||
useKeyboard(
|
useShortcut("j", () => {
|
||||||
(key) => {
|
|
||||||
if (key.name == "j" || key.name == "down") {
|
|
||||||
if (key.shift && selectedFrom == undefined) {
|
|
||||||
setSelectedFrom(idx);
|
|
||||||
}
|
|
||||||
setIdx((prev) => Math.min(prev + 1, data.length - 1));
|
setIdx((prev) => Math.min(prev + 1, data.length - 1));
|
||||||
} else if (key.name == "k" || key.name == "up") {
|
});
|
||||||
if (key.shift && selectedFrom == undefined) {
|
useShortcut("k", () => {
|
||||||
setSelectedFrom(idx);
|
|
||||||
}
|
|
||||||
setIdx((prev) => Math.max(prev - 1, 0));
|
setIdx((prev) => Math.max(prev - 1, 0));
|
||||||
} else if (key.name == "g" && key.shift) {
|
});
|
||||||
setIdx(data.length - 1);
|
|
||||||
} else if (key.name == "v") {
|
|
||||||
setSelectedFrom(idx);
|
|
||||||
} else if (key.name == "escape") {
|
|
||||||
setSelectedFrom(undefined);
|
|
||||||
} else {
|
|
||||||
const from = selectedFrom ? Math.min(idx, selectedFrom) : idx;
|
|
||||||
const to = selectedFrom ? Math.max(idx, selectedFrom) : idx;
|
|
||||||
const selected = data.slice(from, to + 1);
|
|
||||||
if (onKey) onKey(key, selected);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[data, idx, selectedFrom],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnMap = new Map(
|
const columnMap = new Map(
|
||||||
columns.map((col) => {
|
columns.map((col) => {
|
||||||
|
|||||||
30
packages/ui/lib/shortcuts/Debug.tsx
Normal file
30
packages/ui/lib/shortcuts/Debug.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { keysStore } from "./store";
|
||||||
|
|
||||||
|
export function ShortcutDebug() {
|
||||||
|
const entries = useSyncExternalStore(
|
||||||
|
keysStore.subscribe,
|
||||||
|
keysStore.getSnapshot,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 100,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: "black",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "red", fontFamily: "mono" }}>
|
||||||
|
{entries
|
||||||
|
.values()
|
||||||
|
.map(([key, _]) => key)
|
||||||
|
.toArray()
|
||||||
|
.join(",")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/lib/shortcuts/Provider.tsx
Normal file
12
packages/ui/lib/shortcuts/Provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useKeyboard } from "@opentui/react";
|
||||||
|
import { keysStore } from "./store";
|
||||||
|
|
||||||
|
export function ShortcutProvider({ children }: { children: ReactNode }) {
|
||||||
|
useKeyboard((e) => {
|
||||||
|
const fn = keysStore.getHandler(e.name);
|
||||||
|
fn?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
13
packages/ui/lib/shortcuts/Provider.web.tsx
Normal file
13
packages/ui/lib/shortcuts/Provider.web.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { keysStore } from "./store";
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
const fn = keysStore.getHandler(e.key);
|
||||||
|
fn?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortcutProvider({ children }: { children: ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
14
packages/ui/lib/shortcuts/hooks.ts
Normal file
14
packages/ui/lib/shortcuts/hooks.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { keysStore } from "./store";
|
||||||
|
|
||||||
|
export const useShortcut = (key: string, handler: () => void) => {
|
||||||
|
const ref = useRef(handler);
|
||||||
|
ref.current = handler;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
keysStore.register(key, ref);
|
||||||
|
return () => {
|
||||||
|
keysStore.deregister(key);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
3
packages/ui/lib/shortcuts/index.ts
Normal file
3
packages/ui/lib/shortcuts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./Debug";
|
||||||
|
export * from "./Provider";
|
||||||
|
export * from "./store";
|
||||||
40
packages/ui/lib/shortcuts/store.ts
Normal file
40
packages/ui/lib/shortcuts/store.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { type RefObject } from "react";
|
||||||
|
|
||||||
|
// internal map
|
||||||
|
const keys = new Map<string, RefObject<() => void>>();
|
||||||
|
|
||||||
|
// cached snapshot (stable reference)
|
||||||
|
let snapshot: [string, RefObject<() => void>][] = [];
|
||||||
|
|
||||||
|
let listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
// refresh snapshot ONLY when keys actually change
|
||||||
|
snapshot = Array.from(keys.entries());
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keysStore = {
|
||||||
|
subscribe(fn: () => void) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSnapshot() {
|
||||||
|
return snapshot; // stable unless emit() ran
|
||||||
|
},
|
||||||
|
|
||||||
|
register(key: string, ref: RefObject<() => void>) {
|
||||||
|
keys.set(key, ref);
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
deregister(key: string) {
|
||||||
|
keys.delete(key);
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
getHandler(key: string) {
|
||||||
|
return keys.get(key)?.current;
|
||||||
|
},
|
||||||
|
};
|
||||||
72
packages/ui/src/budget.tsx
Normal file
72
packages/ui/src/budget.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { use } from "react";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { RouterContext } from ".";
|
||||||
|
import { queries, type Mutators, type Schema } from "@money/shared";
|
||||||
|
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||||
|
import * as Table from "../components/Table";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
|
||||||
|
const COLUMNS: Table.Column[] = [{ name: "label", label: "Name" }];
|
||||||
|
|
||||||
|
export function Budget() {
|
||||||
|
const { auth } = use(RouterContext);
|
||||||
|
const [budgets] = useQuery(queries.getBudgets(auth));
|
||||||
|
// const [items] = useQuery(queries.getBudgetCategories(auth));
|
||||||
|
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
const z = useZero<Schema, Mutators>();
|
||||||
|
|
||||||
|
const newBudget = () => {
|
||||||
|
const id = new Date().getTime().toString();
|
||||||
|
z.mutate.budget.create({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (budgets.length == 0)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: "mono" }}>
|
||||||
|
No budgets, please create a new budget
|
||||||
|
</Text>
|
||||||
|
<Button onPress={newBudget} shortcut="n">
|
||||||
|
New budget
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const budget = budgets[0]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontFamily: "mono" }}>
|
||||||
|
Selected Budget: {budget.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Table.Provider
|
||||||
|
data={items}
|
||||||
|
columns={COLUMNS}
|
||||||
|
onKey={(event) => {
|
||||||
|
if (event.name == "n" && event.shift) {
|
||||||
|
newBudget();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={{ flexShrink: 0 }}>
|
||||||
|
<Table.Body />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Table.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import { createContext, use } from "react";
|
import { createContext, use, useEffect, useRef } from "react";
|
||||||
import { Transactions } from "./transactions";
|
import { Transactions } from "./transactions";
|
||||||
import { View, Text } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Settings } from "./settings";
|
import { Settings } from "./settings";
|
||||||
import { useKeyboard } from "./useKeyboard";
|
|
||||||
import type { AuthData } from "@money/shared/auth";
|
import type { AuthData } from "@money/shared/auth";
|
||||||
|
import { Budget } from "./budget";
|
||||||
|
import { ShortcutProvider, ShortcutDebug, keysStore } from "../lib/shortcuts";
|
||||||
|
import { useShortcut } from "../lib/shortcuts/hooks";
|
||||||
|
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
"/": {
|
"/": {
|
||||||
screen: <Transactions />,
|
screen: <Transactions />,
|
||||||
key: "1",
|
key: "1",
|
||||||
},
|
},
|
||||||
|
"/budget": {
|
||||||
|
screen: <Budget />,
|
||||||
|
key: "2",
|
||||||
|
},
|
||||||
"/settings": {
|
"/settings": {
|
||||||
screen: <Settings />,
|
screen: <Settings />,
|
||||||
key: "2",
|
key: "3",
|
||||||
children: {
|
children: {
|
||||||
"/accounts": {},
|
"/accounts": {},
|
||||||
"/family": {},
|
"/family": {},
|
||||||
@@ -59,7 +65,10 @@ type AppProps = {
|
|||||||
export function App({ auth, route, setRoute }: AppProps) {
|
export function App({ auth, route, setRoute }: AppProps) {
|
||||||
return (
|
return (
|
||||||
<RouterContext.Provider value={{ auth, route, setRoute }}>
|
<RouterContext.Provider value={{ auth, route, setRoute }}>
|
||||||
|
<ShortcutProvider>
|
||||||
|
<ShortcutDebug />
|
||||||
<Main />
|
<Main />
|
||||||
|
</ShortcutProvider>
|
||||||
</RouterContext.Provider>
|
</RouterContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,17 +76,9 @@ export function App({ auth, route, setRoute }: AppProps) {
|
|||||||
function Main() {
|
function Main() {
|
||||||
const { route, setRoute } = use(RouterContext);
|
const { route, setRoute } = use(RouterContext);
|
||||||
|
|
||||||
useKeyboard((key) => {
|
for (const [route, page] of Object.entries(PAGES)) {
|
||||||
const screen = Object.entries(PAGES).find(
|
useShortcut(page.key, () => setRoute(route as Route));
|
||||||
([, screen]) => screen.key == key.name,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (!screen) return;
|
|
||||||
|
|
||||||
const [route] = screen as [Route, never];
|
|
||||||
|
|
||||||
setRoute(route);
|
|
||||||
});
|
|
||||||
|
|
||||||
const match =
|
const match =
|
||||||
route in PAGES
|
route in PAGES
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { RouterContext, type Route } from ".";
|
|||||||
import { General } from "./settings/general";
|
import { General } from "./settings/general";
|
||||||
import { Accounts } from "./settings/accounts";
|
import { Accounts } from "./settings/accounts";
|
||||||
import { Family } from "./settings/family";
|
import { Family } from "./settings/family";
|
||||||
import { useKeyboard } from "./useKeyboard";
|
|
||||||
import { Modal } from "react-native-opentui";
|
|
||||||
|
|
||||||
type SettingsRoute = Extract<Route, `/settings${string}`>;
|
type SettingsRoute = Extract<Route, `/settings${string}`>;
|
||||||
|
|
||||||
@@ -32,28 +30,27 @@ type Tab = keyof typeof TABS;
|
|||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { route, setRoute } = use(RouterContext);
|
const { route, setRoute } = use(RouterContext);
|
||||||
|
|
||||||
useKeyboard(
|
// useKeyboard(
|
||||||
(key) => {
|
// (key) => {
|
||||||
if (key.name == "h") {
|
// if (key.name == "h") {
|
||||||
const currentIdx = Object.entries(TABS).findIndex(
|
// const currentIdx = Object.entries(TABS).findIndex(
|
||||||
([tabRoute, _]) => tabRoute == route,
|
// ([tabRoute, _]) => tabRoute == route,
|
||||||
);
|
// );
|
||||||
const routes = Object.keys(TABS) as SettingsRoute[];
|
// const routes = Object.keys(TABS) as SettingsRoute[];
|
||||||
const last = routes[currentIdx - 1];
|
// const last = routes[currentIdx - 1];
|
||||||
if (!last) return;
|
// if (!last) return;
|
||||||
setRoute(last);
|
// setRoute(last);
|
||||||
} else if (key.name == "l") {
|
// } else if (key.name == "l") {
|
||||||
const currentIdx = Object.entries(TABS).findIndex(
|
// const currentIdx = Object.entries(TABS).findIndex(
|
||||||
([tabRoute, _]) => tabRoute == route,
|
// ([tabRoute, _]) => tabRoute == route,
|
||||||
);
|
// );
|
||||||
const routes = Object.keys(TABS) as SettingsRoute[];
|
// const routes = Object.keys(TABS) as SettingsRoute[];
|
||||||
const next = routes[currentIdx + 1];
|
// const next = routes[currentIdx + 1];
|
||||||
if (!next) return;
|
// if (!next) return;
|
||||||
setRoute(next);
|
// setRoute(next);
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
[route],
|
// );
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: "row" }}>
|
<View style={{ flexDirection: "row" }}>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { queries, type Mutators, type Schema } from "@money/shared";
|
|||||||
import { use, useEffect, useState } from "react";
|
import { use, useEffect, useState } from "react";
|
||||||
import { RouterContext } from "..";
|
import { RouterContext } from "..";
|
||||||
import { View, Text, Linking } from "react-native";
|
import { View, Text, Linking } from "react-native";
|
||||||
import { useKeyboard } from "../useKeyboard";
|
|
||||||
import { Button } from "../../components/Button";
|
import { Button } from "../../components/Button";
|
||||||
import * as Table from "../../components/Table";
|
import * as Table from "../../components/Table";
|
||||||
import * as Dialog from "../../components/Dialog";
|
import * as Dialog from "../../components/Dialog";
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { useKeyboard as useOpentuiKeyboard } from "@opentui/react";
|
|
||||||
|
|
||||||
export function useKeyboard(
|
|
||||||
handler: Parameters<typeof useOpentuiKeyboard>[0],
|
|
||||||
_deps: any[] = [],
|
|
||||||
) {
|
|
||||||
return useOpentuiKeyboard(handler);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import type { KeyboardEvent } from "react";
|
|
||||||
import type { KeyEvent } from "@opentui/core";
|
|
||||||
|
|
||||||
function convertName(keyName: string): string {
|
|
||||||
const result = keyName.toLowerCase();
|
|
||||||
if (result == "arrowdown") return "down";
|
|
||||||
if (result == "arrowup") return "up";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboard(
|
|
||||||
handler: (key: KeyEvent) => void,
|
|
||||||
deps: any[] = [],
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handlerWeb = (event: KeyboardEvent) => {
|
|
||||||
// @ts-ignore
|
|
||||||
handler({
|
|
||||||
name: convertName(event.key),
|
|
||||||
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,
|
|
||||||
preventDefault: () => event.preventDefault(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
window.addEventListener("keydown", handlerWeb);
|
|
||||||
return () => {
|
|
||||||
// @ts-ignore
|
|
||||||
window.removeEventListener("keydown", handlerWeb);
|
|
||||||
};
|
|
||||||
}, deps);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
},
|
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "DOM"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user