ink starting

This commit is contained in:
Tim Bendt
2024-11-01 15:42:50 -05:00
parent 7f782d6b45
commit ee5c5c9d6e
13 changed files with 1294 additions and 769 deletions

View File

@@ -4,7 +4,7 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "tsx src/index.ts" "start": "tsx src/index.tsx"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -14,6 +14,8 @@
"@biomejs/biome": "1.7.0", "@biomejs/biome": "1.7.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/react": "^18.3.12",
"boxen": "^8.0.1",
"cli-spinners": "^3.0.0", "cli-spinners": "^3.0.0",
"cli-table3": "^0.6.5", "cli-table3": "^0.6.5",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
@@ -22,8 +24,10 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-sheriff": "^18.2.0", "eslint-config-sheriff": "^18.2.0",
"eslint-define-config": "^2.1.0", "eslint-define-config": "^2.1.0",
"ink": "^5.0.1",
"ky": "^1.2.3", "ky": "^1.2.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"react": "^18.3.1",
"tinyrainbow": "^1.1.1", "tinyrainbow": "^1.1.1",
"tsx": "4.7.2", "tsx": "4.7.2",
"typescript": "5.4.5", "typescript": "5.4.5",

1527
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +0,0 @@
import * as w from "wretched";
import Table from "cli-table3";
import {
getOneInvoice,
markInvoicePaid,
updateInvoice,
} from "../services/pancakeApi.js";
import { doubleBoxWithTitle } from "./helpers/boxBorders.js";
import { isNaN, omit, values } from "lodash-es";
import { fromUnixTime, format } from "date-fns";
import { InvoiceDetailsResponse } from "../services/invoiceResponse.js";
export const renderInvoiceDetails = async (invoiceId: string | number) => {
const { invoice } = await getOneInvoice(invoiceId.toString());
if (process.env.DEBUG) {
console.log("🚀 ~ renderInvoiceDetails ~ details:", invoice);
}
const mainWindow = new w.Scrollable({
children: [],
});
const mainTable = new Table();
for (const key in omit(invoice, "items", "partial_payments")) {
const rawValue = invoice[key as keyof InvoiceDetailsResponse];
if (typeof rawValue !== "object") {
let value: Table.Cell = rawValue;
if (typeof rawValue !== "boolean") {
if (key.indexOf("date") >= 0) {
value = Number(rawValue);
if (!isNaN(value)) {
value = format(fromUnixTime(value), "y-M-d");
}
}
}
mainTable.push({
[key]: value,
} as Table.VerticalTableRow);
}
}
mainWindow.addAll([
new w.Flow({
direction: "topToBottom",
children: [
new w.Text({ text: `Unpaid: $${invoice.unpaid_amount}` }),
new w.Button({
text: `Mark as ${invoice.unpaid_amount > 0 ? "Paid" : "Unpaid"}`,
onClick: async () => {
const isPaid = invoice.unpaid_amount > 0;
if (process.env.DEBUG) {
console.log("Switching paid status from", {
isPaid,
invoiceId,
});
}
if (isPaid) {
mainWindow.removeAllChildren();
mainWindow.addAll([
new w.Flow({
direction: "leftToRight",
children: [
new w.Text({ text: "Paid Amount" }),
new w.Input({ text: "Paid Amount" }),
],
}),
]);
} else {
if (!process.env.DEBUG) {
await markInvoicePaid(invoiceId);
}
}
},
}),
new w.Text({ text: mainTable.toString() }),
],
}),
]);
return new w.Box({
border: doubleBoxWithTitle(
`${invoice.client_name} 💸 Invoice: ${invoice.id}`,
),
child: mainWindow,
});
};

View File

@@ -0,0 +1,93 @@
import React, {useState, useEffect} from "react";
import { Text, Box, u } from "ink";
import Table from "cli-table3";
import {
getOneInvoice,
markInvoicePaid,
updateInvoice,
} from "../services/pancakeApi.js";
import { isNaN, omit, values } from "lodash-es";
import { fromUnixTime, format } from "date-fns";
import { InvoiceDetailsResponse } from "../services/invoiceResponse.js";
export const InvoiceDetails = (props: { invoiceId: string | number }) => {
const mainTable = new Table();
const [ invoice, setInvoice ] = useState<InvoiceDetailsResponse | undefined>();
useEffect(() => {
getOneInvoice(props.invoiceId.toString()).then(({ status, invoice }) => {
if (process.env.DEBUG) {
console.log("🚀 ~ renderInvoiceDetails ~ details:", invoice);
}
setInvoice(invoice);
});
}, [props.invoiceId]);
if (invoice) {
for (const key in omit(invoice, "items", "partial_payments")) {
const rawValue = invoice[key as keyof InvoiceDetailsResponse];
if (typeof rawValue !== "object") {
let value: Table.Cell = rawValue;
if (typeof rawValue !== "boolean") {
if (key.indexOf("date") >= 0) {
value = Number(rawValue);
if (!isNaN(value)) {
value = format(fromUnixTime(value), "y-M-d");
}
}
}
mainTable.push({
[key]: value,
} as Table.VerticalTableRow);
}
}
}
}
// mainWindow.addAll([
// new w.Flow({
// direction: "topToBottom",
// children: [
// new w.Text({ text: `Unpaid: $${invoice.unpaid_amount}` }),
// new w.Button({
// text: `Mark as ${invoice.unpaid_amount > 0 ? "Paid" : "Unpaid"}`,
// onClick: async () => {
// const isPaid = invoice.unpaid_amount > 0;
// if (process.env.DEBUG) {
// console.log("Switching paid status from", {
// isPaid,
// invoiceId,
// });
// }
// if (isPaid) {
// mainWindow.removeAllChildren();
// mainWindow.addAll([
// new w.Flow({
// direction: "leftToRight",
// children: [
// new w.Text({ text: "Paid Amount" }),
// new w.Input({ text: "Paid Amount" }),
// ],
// }),
// ]);
// } else {
// if (!process.env.DEBUG) {
// await markInvoicePaid(invoiceId);
// }
// }
// },
// }),
// new w.Text({ text: mainTable.toString() }),
// ],
// }),
// ]);
return <Box flexDirection="column">
<Text>{invoice.client_name} 💸 Invoice: ${invoice.id}</Text>
<Text>{mainTable.toString()}</Text>
</Box>
};

View File

@@ -0,0 +1,16 @@
import boxen from "boxen";
export default function (width?: number) {
return [
boxen("child 1", {
borderColor: "red",
width,
float: "left",
}),
boxen("child 2", {
borderColor: "magentaBright",
width,
float: "right",
}),
];
}

View File

@@ -1,12 +1,5 @@
import { import React from "react";
Box, import { Box, Text } from "ink";
Button,
Container,
Flex,
Screen,
ScrollableList,
Text,
} from "wretched";
import { import {
ClientResponse, ClientResponse,
InvoiceResponse, InvoiceResponse,
@@ -17,7 +10,7 @@ import {
import spinners from "cli-spinners"; import spinners from "cli-spinners";
import { compact, find, isNil, isString, reverse, sortBy } from "lodash-es"; import { compact, find, isNil, isString, reverse, sortBy } from "lodash-es";
import { renderInvoiceList } from "./listInvoices.js"; import { renderInvoiceList } from "./listInvoices.js";
import { renderInvoiceDetails } from "./detailsInvoice.js"; import { InvoiceDetails } from "./detailsInvoice.js";
import { doubleBoxWithTitle } from "./helpers/boxBorders.js"; import { doubleBoxWithTitle } from "./helpers/boxBorders.js";
import { AnimatedText } from "./helpers/animatedText.js"; import { AnimatedText } from "./helpers/animatedText.js";
@@ -50,55 +43,29 @@ const renderInvoicesBox = async (
selectedClientId?: string, selectedClientId?: string,
selectedInvoiceId?: string, selectedInvoiceId?: string,
) => { ) => {
let InvoicesBox = new Box({ child: new Text({ text: "« Select a Client" }) }); let InvoicesBox = <Box><Text>« Select a Client</Text></Box>;
if (selectedInvoiceId && selectedClientId) { if (selectedInvoiceId && selectedClientId) {
InvoicesBox = await renderInvoiceDetails(selectedInvoiceId); InvoicesBox = <InvoiceDetails id={selectedInvoiceId}></InvoiceDetails>;
} else if (selectedClientId) { } else if (selectedClientId) {
const currClient = find(sorted, { id: selectedClientId }); const currClient = find(sorted, { id: selectedClientId });
const currentClientName = `${currClient?.company} | ${currClient?.first_name} ${currClient?.last_name}`; const currentClientName = `${currClient?.company} | ${currClient?.first_name} ${currClient?.last_name}`;
InvoicesBox = new Box({ InvoicesBox = await renderInvoiceList(selectedClientId, (id) => {
width: "fill",
height: "fill",
child: await renderInvoiceList(selectedClientId, (id) => {
console.log("clicked invoice", id); console.log("clicked invoice", id);
reDrawMain(selectedClientId, id.toString()); reDrawMain(selectedClientId, id.toString());
}), });
border: doubleBoxWithTitle(currentClientName),
});
} }
return InvoicesBox; return InvoicesBox;
}; };
const cellForItem = (client: ClientResponse) => {
let Clients = new ScrollableList({
minWidth: 20,
width: "natural",
cellForItem: (client: ClientResponse) => {
if (!client) { if (!client) {
// console.log("empty client company name", item); // console.log("empty client company name", item);
return new Text({ text: `Undefined Client` }); return <text>(Undefined Client)</text>
} }
return new Button({ return <Text>{client.company}</Text>
border: "none", };
width: "natural",
text: `${client.company}`, export const Clients = <Box minWidth={20} width={"100%"}>{sorted.map(cellForItem)}</Box>
onClick: async () => {
console.log("🚀 ~ clientInvoicesView ~ clicked client:", client);
reDrawMain(client.id);
},
});
},
items: sorted,
});
const renderRightPanel = (box: Box) =>
Flex.down({
width: "fill",
height: "fill",
padding: { left: 1, right: 1 },
children: [box],
});
export const clientInvoicesView = Flex.right({
children: [Clients, renderRightPanel(await renderInvoicesBox())],
});

View File

@@ -1,38 +0,0 @@
import { Box, Button, Flex, Text } from "wretched";
import { Style } from "wretched/dist/Style.js";
const tabs = {
Home: "/",
Clients: "/",
Invoices: "/",
Projects: "/",
} as const;
export const mainNav = (props: { activeTabName: keyof typeof tabs }) => {
return new Flex({
direction: "leftToRight",
children: Object.keys(tabs).map(
(x) =>
new Box({
padding: { top: 0, bottom: 0, left: 2, right: 2 },
// child: new Text({ text: x, style: new Style({ bold: true }) }),
child: new Button({
text: x,
border: "none",
theme: props.activeTabName === x ? "plain" : "selected",
onClick: () => {
console.log({ clicked: x });
},
}),
border: [
"",
"",
"",
"",
"",
"",
props.activeTabName === x ? "━" : "─",
"",
],
}),
),
});
};

View File

@@ -0,0 +1,21 @@
import React from "react";
import { Box, Text } from "ink";
const tabs = {
Home: "/",
Clients: "/",
Invoices: "/",
Projects: "/",
} as const;
export const MainNav = (props: { activeTabName: keyof typeof tabs }) => {
const tabBoxes = Object.keys(tabs).map(x => props.activeTabName === x ?
<Text backgroundColor={"blueBright"} bold>[ <Text color={"red"}>({x.charAt(0)})</Text> {`${x}`} ]</Text>
:<Text backgroundColor={"blue"} >[ <Text color={"red"}>({x.charAt(0)})</Text>{` ${x} `} ]</Text>);
return <Box width="100%" flexDirection="row" columnGap={0}>
<Text backgroundColor={"blueBright"} bold>[ <Text color={"red"}>(h)</Text>Home ]</Text>
<Text backgroundColor={"blue"} >[ <Text color={"red"}>(c)</Text> Clients ]</Text>
<Text backgroundColor={"blue"} >[ <Text color={"red"}>(p)</Text> Projects ]</Text>
<Text backgroundColor={"blue"} >[ <Text color={"red"}>(i)</Text> Invoices ]</Text>
</Box>;
};

View File

@@ -1,47 +0,0 @@
import "dotenv/config";
import {
Screen,
Box,
Flow,
Text,
Button,
interceptConsoleLog,
ConsoleLog,
iTerm2,
Window,
Flex,
} from "wretched";
import * as utility from "wretched/dist/components/utility";
import { clientInvoicesView } from "./components/listClients.js";
import { mainNav } from "./components/mainNav.js";
import { inspect } from "node:util";
interceptConsoleLog();
process.title = "Wretched";
const consoleLog = new ConsoleLog({
height: 12,
});
const [screen, program] = await Screen.start(async (program) => {
await iTerm2.setBackground(program, [23, 23, 23]);
return new Window({
child: Flex.down({
padding: { top: 1 },
children: [
["natural", mainNav({ activeTabName: "Clients" })],
["flex1", clientInvoicesView],
["natural", consoleLog],
],
}),
});
});
program.key("escape", function () {
consoleLog.clear();
screen.render();
});
process.on("uncaughtException", (error) => {
console.error("\r\nUncaught Exception was Caught!", inspect(error));
});

95
src/index.tsx Normal file
View File

@@ -0,0 +1,95 @@
import React from "react";
import "dotenv/config";
import { interceptConsoleLog, ConsoleLog, iTerm2 } from "wretched";
import { clientInvoicesView } from "./components/listClients.js";
import { MainNav } from "./components/mainNav.js";
import { inspect } from "node:util";
import { emitKeypressEvents } from "node:readline";
import { render, Box, Text, Newline } from "ink";
const currentGlobalState = { count: 0, logs: [""], };
const activeTabName: "Home" | "Clients" = "Home";
function log(message: unknown) {
currentGlobalState.logs.push(inspect(message));
};
const Main = () =>
<Box width="100%" flexDirection="column" alignItems="stretch">
<Box width="100%" columnGap={2}>
<Text backgroundColor={"blackBright"}>PANCAKE-TUI 🮥 </Text><MainNav activeTabName={activeTabName}/>
</Box>
<Box width="100%"
flexDirection="row" justifyContent="center" alignItems="stretch">
<Box width="25%" borderColor="greenBright" borderStyle={{
top: "─",
topRight: "┬",
right: "│",
bottomRight: "┴",
bottom: "─",
bottomLeft: "└",
left: "│",
topLeft: "┌"
}}>
<Text bold underline>Left Sidebar</Text><Newline/>
</Box>
<Box flexGrow={1} borderStyle="single" borderColor="greenBright" borderLeft={false}>
<Text bold underline>Main Area</Text><Newline/>
<Text>Hello World {currentGlobalState.count}</Text>
</Box>
</Box>
<Text backgroundColor={"gray"}>🔔 Status[{currentGlobalState.logs.pop()}]</Text>
</Box>;
function renderState() {
currentGlobalState.count += 1;
render(<Main />);
}
function renderRaw() {
console.clear();
// process.stdout.write(renderState());
}
interceptConsoleLog();
process.title = "TUI demo";
// await iTerm2.setBackground(program, [23, 23, 23]);
// child: Flex.down({
// padding: { top: 1 },
// children: [
// ["natural", mainNav({ activeTabName: "Clients" })],
// ["flex1", clientInvoicesView],
// ["natural", consoleLog],
// ],
// }),
// });
// });
emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.on("keypress", (str, key) => {
if (key.ctrl && key.name === "c") {
process.exit();
} else if (key.name === "escape") {
currentGlobalState.logs = [""];
renderState();
} else {
log(`You pressed ${key.name}`);
}
});
renderState();
log("Press any key, or Ctrl+C to exit.");
// program.key("escape", function () {
// consoleLog.clear();
// screen.render();
// });
process.on("uncaughtException", (error) => {
console.error("\r\nUncaught Exception was Caught!", inspect(error));
});

17
src/lib/cols.ts Normal file
View File

@@ -0,0 +1,17 @@
export const terminalColumns = () => {
const { env, stdout, stderr } = process;
if (stdout?.columns) {
return stdout.columns;
}
if (stderr?.columns) {
return stderr.columns;
}
if (env.COLUMNS) {
return Number.parseInt(env.COLUMNS, 10);
}
return 80;
};

53
src/lib/merge.ts Normal file
View File

@@ -0,0 +1,53 @@
export function xorMergeStrings(strings: string[]) {
// Split the input strings into arrays of lines
const [str1, str2] = strings;
const lines1 = str1.split("\n");
console.log("🚀 ~ xorMergeStrings ~ lines1:", lines1);
const lines2 = str2.split("\n");
console.log("🚀 ~ xorMergeStrings ~ lines2:", lines2);
// Determine the maximum number of lines
const maxLines = Math.max(lines1.length, lines2.length);
// Initialize an array to hold the merged lines
const mergedLines = [];
// Iterate through each line index up to the maximum number of lines
for (let i = 0; i < maxLines; i++) {
// Get the current line from each string, or an empty string if the line does not exist
const maxLength = Math.max(lines1[i].length, lines2[i].length);
const line1 = lines1[i].padEnd(maxLength, " ");
console.log("🚀 ~ xorMergeStrings ~ line1: \n", line1);
const line2 = lines2[i].padEnd(maxLength, " ");
console.log("🚀 ~ xorMergeStrings ~ line2: \n", line2);
// Determine the maximum length of the current lines
console.log("🚀 ~ xorMergeStrings ~ maxLength:", maxLength);
// Initialize an array to hold the merged characters for the current line
const mergedLine = [];
// Iterate through each character index up to the maximum length
for (let j = 0; j < maxLength; j++) {
// Get the current character from each line, or a space if the character does not exist
const char1 = line1[j];
const char2 = line2[j];
// Determine if each character is a whitespace character
const isWhitespace1 = char1.trim() === "";
const isWhitespace2 = char2.trim() === "";
// XOR merge the characters based on the whitespace criteria
const mergedChar = isWhitespace1 ? char2 : char1;
// Add the merged character to the merged line
mergedLine.push(mergedChar);
}
// Join the merged line array into a string and add it to the merged lines array
mergedLines.push(mergedLine.join(""));
}
// Join the merged lines array into a single string with newline characters
return mergedLines.join("\n");
}

View File

@@ -4,18 +4,19 @@
"target": "es2022", "target": "es2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": false, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"incremental": true, "incremental": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"tsBuildInfoFile": null "tsBuildInfoFile": null,
"jsx": "react-jsx"
}, },
"include": [ "include": [
"**/*.ts" "**/*.ts"
], , "src/index.tsx", "src/components/mainNav.tsx", "src/components/detailsInvoice.tsx", "src/components/listClients.tsx" ],
"exclude": [ "exclude": [
"node_modules", "node_modules",
"**/mocks/**/*", "**/mocks/**/*",