feat: initial commit

This commit is contained in:
2026-06-29 23:07:14 +00:00
commit 3b9a6bc85c
152 changed files with 12558 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { Box, useApp, useInput } from "ink";
import { use, useState, type ComponentType, type ReactNode } from "react";
import { ListBox } from "./components/ui/ListBox.js";
import type { DbInterface } from "../db/types/DbInterface.js";
import { DbProvider } from "../react/contexts/Db.js";
import { UsersPanel } from "./components/panels/UsersPanel.js";
import { BgColorContext } from "./contexts/BgColor.js";
import { FocusManager, useActive } from "./contexts/FocusManager.js";
import { Provider } from "../react/store/react.js";
import { createStore, type Store } from "../react/store/store.js";
import { AbodesPanel } from "./components/panels/AbodesPanel.js";
import { LoginPanel } from "./components/panels/LoginPanel.js";
import { PopupManager } from "../react/contexts/PopupManager.js";
function AppWrapper({
children,
db,
store,
bgColor,
}: {
children: ReactNode;
db: DbInterface;
store: Store;
bgColor?: string;
}) {
return (
<FocusManager>
<Provider store={store}>
<DbProvider db={db}>
<BgColorContext value={bgColor ?? use(BgColorContext)}>
<Box flexGrow={1} flexDirection="column">
<Box height={1} />
<Box alignItems="stretch" flexGrow={1}>
<PopupManager>{children}</PopupManager>
</Box>
</Box>
</BgColorContext>
</DbProvider>
</Provider>
</FocusManager>
);
}
export type CollectionType = "users" | "abodes" | "apikeys" | "notes";
const collectionTypes: CollectionType[] = [
"users",
"abodes",
"apikeys",
"notes",
];
const collectionTypeDisplay: Record<CollectionType, string> = {
users: "Users",
abodes: "Abodes",
apikeys: "API Keys",
notes: "Notes",
};
const collections: Record<CollectionType, ComponentType> = {
users: UsersPanel,
abodes: AbodesPanel,
apikeys: () => null,
notes: () => null,
};
function App() {
const app = useApp();
const isActive = useActive();
useInput(
(input, key) => {
if (input === "q" || key.escape) app.exit();
},
{ isActive }
);
const [activeCollection, setActiveCollection] =
useState<CollectionType>("users");
const CollectionPanel = collections[activeCollection];
return (
<Box flexGrow={1} flexDirection="row">
<ListBox
width={13}
title="Collections"
items={collectionTypes}
display={collectionTypeDisplay}
autoFocus
selected={activeCollection}
setSelected={setActiveCollection}
/>
<Box flexGrow={1} flexDirection="column">
<LoginPanel />
<CollectionPanel />
</Box>
</Box>
);
}
export function app({
db,
bgColor,
store = createStore(),
}: {
db: DbInterface;
bgColor?: string;
store?: Store;
}) {
return (
<AppWrapper db={db} store={store} bgColor={bgColor}>
<App />
</AppWrapper>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { Box, Text } from "ink";
import { usePanelSize } from "../../hooks/size.js";
import { EllipsisText } from "../ui/EllipsisText.js";
import { use, useCallback, useMemo } from "react";
import { type ButtonListItem } from "../ui/Button.js";
import type { Abode } from "../../../db/types/Abode.js";
import { useDataAllAbodes } from "../../../react/hooks/data/abodes.js";
import { lengthOfUuid } from "../../../util/length.js";
import { PopupManagerContext } from "../../../react/contexts/PopupManager.js";
import { SearchPanel } from "../ui/SearchPanel.js";
import { AbodePopup } from "../popups/AbodePopup.js";
import { CreateAbodePopup } from "../popups/CreateAbodePopup.js";
function AbodeComponent({
item,
selected,
}: {
item: Abode;
selected: boolean;
}) {
const { width } = usePanelSize();
let maxNameLen = width - 2 - lengthOfUuid - 1;
return (
<Box height={1}>
<Text inverse={selected}>
<EllipsisText maxLen={maxNameLen} text={item.name} fill=" " />{" "}
<Text dimColor>{item.aid}</Text>
</Text>
</Box>
);
}
const match = (filter: string, abode: Abode) =>
abode.name.toLowerCase().includes(filter.toLowerCase());
const sort = (a: Abode, b: Abode) => (a.name < b.name ? -1 : 1);
export function AbodesPanel() {
const { status, abodes, refresh } = useDataAllAbodes();
const { openPopup } = use(PopupManagerContext)!;
const onSelect = useCallback(
(abode: Abode) => openPopup(AbodePopup, { aid: abode.aid }),
[openPopup]
);
const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateAbodePopup) }],
[openPopup]
);
return (
<SearchPanel
status={status}
refresh={refresh}
items={abodes}
match={match}
sort={sort}
onSelect={onSelect}
buttons={buttons}
ItemComponent={AbodeComponent}
/>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Box, Text } from "ink";
import { useManagedFocus } from "../../contexts/FocusManager.js";
import { useDispatch, useSelector } from "../../../react/store/react.js";
import {
getLoginUser,
logOut,
setLoginUser,
} from "../../../react/store/slices/login.js";
import { Button } from "../ui/Button.js";
import { use, useEffect } from "react";
import { DbContext } from "../../../react/contexts/Db.js";
import { Popup } from "../ui/Popup.js";
import { PopupManagerContext } from "../../../react/contexts/PopupManager.js";
import type { DbInterface } from "../../../db/types/DbInterface.js";
import { idAssert } from "../../../util/ts.js";
import type { ApiInterface } from "../../../db/api/ApiInterface.js";
import type { Dispatch } from "../../../react/store/store.js";
import { LoginPopup } from "../popups/LoginPopup.js";
function ConnectPopup({ onClose }: { onClose: () => void }) {
return (
<Popup onClose={onClose}>
<Text>Currently unimplemented</Text>
</Popup>
);
}
// toplevel if's inside are build time
// gets completely removed at build time if empty
function useAutoLogin(db: DbInterface | null, dispatch: Dispatch) {
if (compiledSources.api) {
useEffect(() => {
if (db?.name === "api") {
idAssert<ApiInterface>(db);
db._.self()
.then((user) => dispatch(setLoginUser(user)))
.catch(() => {});
}
}, [db]);
}
}
export function LoginPanel() {
const { isFocused } = useManagedFocus();
const user = useSelector(getLoginUser);
const db = use(DbContext);
const { openPopup } = use(PopupManagerContext)!;
const dispatch = useDispatch();
useAutoLogin(db, dispatch);
return (
<>
<Box
flexGrow={1}
borderStyle="round"
borderDimColor={!isFocused}
height={3}
justifyContent="space-between"
>
{user ? (
<>
<Text>
Logged in as{" "}
<Text color="yellowBright" bold>
{user.name}
</Text>
</Text>
<Button onClick={() => dispatch(logOut())}>Log Out</Button>
</>
) : db ? (
<>
<Text color="red">Not logged in</Text>
<Button onClick={() => openPopup(LoginPopup)}>Log In</Button>
</>
) : (
<>
<Text color="red">No db interface</Text>
<Button onClick={() => openPopup(ConnectPopup)}>Connect</Button>
</>
)}
</Box>
</>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { Box, Text } from "ink";
import { usePanelSize } from "../../hooks/size.js";
import { lengthOfUuid } from "../../../util/length.js";
import { EllipsisText } from "../ui/EllipsisText.js";
import { use, useCallback, useMemo } from "react";
import { type ButtonListItem } from "../ui/Button.js";
import { useDataAllUsers } from "../../../react/hooks/data/users.js";
import type { ClientUser, PartialUser } from "../../../db/types/User.js";
import { PopupManagerContext } from "../../../react/contexts/PopupManager.js";
import { SearchPanel } from "../ui/SearchPanel.js";
import { UserPopup } from "../popups/UserPopup.js";
import { CreateUserPopup } from "../popups/CreateUserPopup.js";
function UserComponent({
item,
selected,
}: {
item: ClientUser | PartialUser;
selected: boolean;
}) {
const { width } = usePanelSize();
let maxNameLen = width - 2 - lengthOfUuid - 1;
let maxEmailLen = 0;
if (maxNameLen > 40) {
maxEmailLen = Math.floor((maxNameLen * 2) / 3);
maxNameLen -= maxEmailLen;
maxEmailLen--;
}
return (
<Box height={1}>
<Text inverse={selected}>
<EllipsisText maxLen={maxNameLen} text={item.name} fill=" " />
{!!maxEmailLen && (
<>
{" "}
<EllipsisText
maxLen={maxEmailLen}
text={"email" in item ? item.email : ""}
fill=" "
/>
</>
)}{" "}
<Text dimColor>{item.uid}</Text>
</Text>
</Box>
);
}
const match = (filter: string, user: ClientUser | PartialUser) =>
user.name.toLowerCase().includes(filter.toLowerCase());
const sort = (a: ClientUser | PartialUser, b: ClientUser | PartialUser) =>
a.name < b.name ? -1 : 1;
export function UsersPanel() {
const { status, users, refresh } = useDataAllUsers();
const { openPopup } = use(PopupManagerContext)!;
const onSelect = useCallback(
(user: ClientUser | PartialUser) => openPopup(UserPopup, { uid: user.uid }),
[openPopup]
);
const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateUserPopup) }],
[openPopup]
);
return (
<SearchPanel
status={status}
refresh={refresh}
items={users}
match={match}
sort={sort}
onSelect={onSelect}
buttons={buttons}
ItemComponent={UserComponent}
/>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { use, useState } from "react";
import { useDataAbodeById } from "../../../react/hooks/data/abodes.js";
import { useAction } from "../../../react/hooks/useAction.js";
import {
deleteAbodeById,
updateAbode,
} from "../../../react/store/actions/abodes.js";
import type { UpdateAbode } from "../../../db/types/Abode.js";
import { useFreeSize } from "../../hooks/size.js";
import { useDataResidentsByAbodeId } from "../../../react/hooks/data/residents.js";
import { Popup } from "../ui/Popup.js";
import { Box, Text } from "ink";
import { Input } from "../ui/Input.js";
import { UserName } from "../ui/UserName.js";
import { Button, ButtonList } from "../ui/Button.js";
import { PopupManagerContext } from "../../../react/contexts/PopupManager.js";
import { AbodeResidentsPopup } from "./AbodeResidentsPopup.js";
export function AbodePopup({
aid,
onClose,
}: {
aid: string;
onClose: () => void;
}) {
const { abode, status } = useDataAbodeById(aid);
const del = useAction(deleteAbodeById);
const upd = useAction(updateAbode);
const [changes, setChanges] = useState<UpdateAbode | null>(null);
const { width } = useFreeSize();
const { residents, status: residentStatus } = useDataResidentsByAbodeId(aid);
const { openPopup } = use(PopupManagerContext)!;
return (
<Popup onClose={!changes ? onClose : undefined}>
<Box flexDirection="column" padding={1} minWidth={width / 3}>
{abode ? (
<>
<Text>
<Text bold>Name:{" "}</Text>
{changes ? (
<Input
autoFocus
value={changes.name ?? abode.name}
onChange={(v) => setChanges((e) => ({ ...e!, name: v }))}
/>
) : (
<>{abode.name}</>
)}
</Text>
<Text>
<Text bold>aid:{" "}</Text>
{abode.aid}
</Text>
<Text>
<Text bold>Created:{" "}</Text>
{abode.created_at} (
{abode.created_by ? (
<UserName uid={abode.created_by} />
) : (
<Text dimColor>N/A</Text>
)}
)
</Text>
<Text>
<Text bold>Updated:{" "}</Text>
{abode.updated_at} (
{abode.updated_by ? (
<UserName uid={abode.updated_by} />
) : (
<Text dimColor>N/A</Text>
)}
)
</Text>
<Text>
<Text bold>Residents: </Text>
{residentStatus === "loaded" ? (
residents.length
) : (
<Text dimColor>{residentStatus}</Text>
)}
</Text>
<Box height={1} />
{changes ? (
<ButtonList
justifyContent="center"
buttons={[
{
children: "Save",
onClick: () => upd(changes).then(() => setChanges(null)),
},
{
children: "Discard",
onClick: () => setChanges(null),
},
]}
/>
) : (
<ButtonList
autoFocus
justifyContent="center"
buttons={[
{
children: "Close",
onClick: onClose,
},
{
children: "Edit",
onClick: () => setChanges({ aid }),
},
{
children: "Residents",
onClick: () => openPopup(AbodeResidentsPopup, { aid }),
},
{
children: "Delete",
onClick: () => del(aid).then(onClose),
},
]}
/>
)}
</>
) : (
<>
<Text dimColor>{status}</Text>
<Box height={1} />
<Button autoFocus onClick={onClose}>
Close
</Button>
</>
)}
</Box>
</Popup>
);
}
@@ -0,0 +1,79 @@
import { Box, Text } from "ink";
import { Popup } from "../ui/Popup.js";
import { useFreeSize } from "../../hooks/size.js";
import { useDataResidentsByAbodeId } from "../../../react/hooks/data/residents.js";
import { AbodeName } from "../ui/AbodeName.js";
import { SearchPanel } from "../ui/SearchPanel.js";
import type { Resident } from "../../../db/types/Resident.js";
import { UserName } from "../ui/UserName.js";
import { ButtonList } from "../ui/Button.js";
function AbodeResidentComponent({
item,
selected,
}: {
item: Resident;
selected: boolean;
}) {
return (
<Box height={1} flexDirection="row" justifyContent="space-between">
<Text underline={selected}>
<UserName uid={item.uid} />
</Text>
{selected && (
<ButtonList
isFocused
buttons={[
{
children: "Delete",
onClick: () => {},
},
{
children: "Delete",
onClick: () => {},
},
{
children: "Delete",
onClick: () => {},
},
]}
/>
)}
</Box>
);
}
export function AbodeResidentsPopup({
aid,
onClose,
}: {
aid: string;
onClose: () => void;
}) {
const { width, height } = useFreeSize();
const { residents, status, refresh } = useDataResidentsByAbodeId(aid);
return (
<Popup onClose={onClose}>
<Box flexDirection="column" minWidth={width / 3}>
<Box justifyContent="center" flexDirection="row">
<Text>
Residents of{" "}
<Text color="yellow" bold>
<AbodeName aid={aid} />
</Text>
</Text>
<Box height={1} />
</Box>
<SearchPanel
sub
status={status}
items={residents}
refresh={refresh}
height={Math.min(height, Math.max(Math.floor(height / 2), 20))}
ItemComponent={AbodeResidentComponent}
/>
</Box>
</Popup>
);
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import type { CreateAbode } from "../../../db/types/Abode.js";
import { useFreeSize } from "../../hooks/size.js";
import { createAbode } from "../../../react/store/actions/abodes.js";
import { useAction } from "../../../react/hooks/useAction.js";
import { Popup } from "../ui/Popup.js";
import { Box, Text } from "ink";
import { Input } from "../ui/Input.js";
import { ButtonList } from "../ui/Button.js";
export function CreateAbodePopup({ onClose }: { onClose: () => void }) {
const [create, setCreate] = useState<CreateAbode>({
name: "",
});
const { width } = useFreeSize();
const add = useAction(createAbode);
return (
<Popup>
<Box flexDirection="column" padding={1} minWidth={width / 3}>
<Text>
<Text bold>Name: </Text>
<Input
autoFocus
value={create.name}
onChange={(v) => setCreate((u) => ({ ...u, name: v }))}
/>
</Text>
<Box height={1} />
<ButtonList
justifyContent="center"
buttons={[
{
children: "Create",
onClick: () => add(create).then(onClose),
},
{ children: "Cancel", onClick: onClose },
]}
/>
</Box>
</Popup>
);
}
@@ -0,0 +1,83 @@
import { useState } from "react";
import type { CreateUser } from "../../../db/types/User.js";
import { useFreeSize } from "../../hooks/size.js";
import { createUser } from "../../../react/store/actions/users.js";
import { useAction } from "../../../react/hooks/useAction.js";
import { Popup } from "../ui/Popup.js";
import { Box, Text } from "ink";
import { Input } from "../ui/Input.js";
import { Button, ButtonList } from "../ui/Button.js";
import { hashPassword } from "../../../util/hash.js";
export function CreateUserPopup({ onClose }: { onClose: () => void }) {
const [create, setCreate] = useState<Omit<CreateUser, "password">>({
name: "",
email: "",
flags: {},
});
const [password, setPassword] = useState("");
const { width } = useFreeSize();
const add = useAction(createUser);
return (
<Popup>
<Box flexDirection="column" padding={1} minWidth={width / 3}>
<Text>
<Text bold>Name:{" "}</Text>
<Input
autoFocus
value={create.name}
onChange={(v) => setCreate((u) => ({ ...u, name: v }))}
/>
</Text>
<Text>
<Text bold>Email:{" "}</Text>
<Input
value={create.email}
onChange={(v) => setCreate((u) => ({ ...u, email: v }))}
/>
</Text>
<Text>
<Text bold>Password: </Text>
<Input
value={password}
onChange={setPassword}
mask="*"
placeholder="(leave blank)"
/>
</Text>
<Text>
<Text bold>Admin:{" "}</Text>
<Button
onClick={() =>
setCreate((e) => ({
...e,
flags: { ...e.flags, admin: !e.flags.admin },
}))
}
>
{create.flags.admin ? "yes" : "no"}
</Button>
</Text>
<Box height={1} />
<ButtonList
justifyContent="center"
buttons={[
{
children: "Create",
onClick: () =>
(password
? hashPassword(password)
: Promise.resolve("#unset" as const)
)
.then((password) => ({ ...create, password }))
.then(add)
.then(onClose),
},
{ children: "Cancel", onClick: onClose },
]}
/>
</Box>
</Popup>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import { useFreeSize } from "../../hooks/size.js";
import { Popup } from "../ui/Popup.js";
import { Box, Text } from "ink";
import { Input } from "../ui/Input.js";
import { Button } from "../ui/Button.js";
import { useDataUserByEmail } from "../../../react/hooks/data/users.js";
import { useDispatch } from "../../../react/store/react.js";
import { setLoginUser } from "../../../react/store/slices/login.js";
function LoginPopupCheck({
email,
onClose,
}: {
email: string;
onClose: () => void;
}) {
const { status, user } = useDataUserByEmail(email);
const dispatch = useDispatch();
useEffect(() => {
if (user) {
dispatch(setLoginUser(user));
onClose();
}
}, [user, onClose]);
return <Text dimColor>{status}</Text>;
}
export function LoginPopup({ onClose }: { onClose: () => void }) {
const [email, setEmail] = useState("");
const [confirmedEmail, setConfirmedEmail] = useState(email);
const { width } = useFreeSize();
return (
<Popup onClose={onClose}>
<Box flexDirection="column" padding={1} minWidth={width / 3}>
<Text>
<Text bold>Email: </Text>
<Input autoFocus value={email} onChange={setEmail} />
</Text>
{confirmedEmail ? (
<LoginPopupCheck email={confirmedEmail} onClose={onClose} />
) : (
<Box height={1} />
)}
<Box justifyContent="center">
<Button onClick={() => setConfirmedEmail(email)}>Log In</Button>
</Box>
</Box>
</Popup>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { Box, Text } from "ink";
import { Button, ButtonList } from "../ui/Button.js";
import { Input } from "../ui/Input.js";
import { Popup } from "../ui/Popup.js";
import { useFreeSize } from "../../hooks/size.js";
import { useState } from "react";
import type { UpdateUser } from "../../../db/types/User.js";
import {
deleteUserById,
updateUser,
} from "../../../react/store/actions/users.js";
import { useDataUserById } from "../../../react/hooks/data/users.js";
import { useAction } from "../../../react/hooks/useAction.js";
export function UserPopup({
uid,
onClose,
}: {
uid: string;
onClose: () => void;
}) {
const { user, status } = useDataUserById(uid);
const del = useAction(deleteUserById);
const upd = useAction(updateUser);
const [changes, setChanges] = useState<UpdateUser | null>(null);
const { width } = useFreeSize();
return (
<Popup onClose={!changes ? onClose : undefined}>
<Box flexDirection="column" padding={1} minWidth={width / 3}>
{user ? (
<>
<Text>
<Text bold>Name:{" "}</Text>
{changes ? (
<Input
autoFocus
value={changes.name ?? user.name}
onChange={(v) => setChanges((e) => ({ ...e!, name: v }))}
/>
) : (
<>{user.name}</>
)}
</Text>
<Text>
<Text bold>Uid:{" "}</Text>
{user.uid}
</Text>
<Text>
<Text bold>Email:{" "}</Text>
{changes ? (
<Input
value={changes.email ?? ("email" in user ? user.email : "")}
onChange={(v) => setChanges((e) => ({ ...e!, email: v }))}
/>
) : (
<>
{"email" in user ? (
<>{user.email}</>
) : (
<Text dimColor>N/A</Text>
)}
</>
)}
</Text>
<Text>
<Text bold>Flags:{" "}</Text>
{(user.flags.admin ?? false) && <Text>admin</Text>}
</Text>
<Text>
<Text bold>Created: </Text>
{user.created_at}
</Text>
<Text>
<Text bold>Updated: </Text>
{user.updated_at}
</Text>
<Box height={1} />
{changes ? (
<ButtonList
justifyContent="center"
buttons={[
{
children: "Save",
onClick: () => upd(changes).then(() => setChanges(null)),
},
{
children: "Discard",
onClick: () => setChanges(null),
},
]}
/>
) : (
<ButtonList
autoFocus
justifyContent="center"
buttons={[
{
children: "Close",
onClick: onClose,
},
{
children: "Edit",
onClick: () => {
setChanges({ uid });
},
},
{
children: "Delete",
onClick: () => del(uid).then(onClose),
},
]}
/>
)}
</>
) : (
<>
<Text dimColor>{status}</Text>
<Box height={1} />
<Button autoFocus onClick={onClose}>
Close
</Button>
</>
)}
</Box>
</Popup>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
import { Text } from "ink";
import { useDataAbodeById } from "../../../react/hooks/data/abodes.js";
export function AbodeName({
aid,
...props
}: { aid: string } & ComponentProps<typeof Text>) {
const { status, abode } = useDataAbodeById(aid);
return abode ? (
<Text {...props}>{abode.name}</Text>
) : (
<Text dimColor>{status}</Text>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useState, type ComponentProps, type ReactNode } from "react";
import { useManagedFocus } from "../../contexts/FocusManager.js";
import { Box, Text, useInput } from "ink";
export function Button({
children,
onClick,
autoFocus,
focusId,
}: {
children: ReactNode;
onClick: () => void;
autoFocus?: boolean;
focusId?: string;
}) {
const { isFocused } = useManagedFocus({ autoFocus, id: focusId });
useInput(
(input, key) => {
if (input === " " || key.return) onClick();
},
{ isActive: isFocused }
);
return <Text inverse={isFocused}>[{children}]</Text>;
}
export type ButtonListItem = {
children: ReactNode;
onClick: () => void;
};
export function ButtonList({
buttons,
autoFocus,
focusId,
isFocused: forceFocus,
horizontal = false,
vertical = false,
...props
}: {
buttons: ButtonListItem[];
autoFocus?: boolean;
focusId?: string;
isFocused?: boolean;
horizontal?: boolean;
vertical?: boolean;
} & Omit<ComponentProps<typeof Box>, "children">) {
if (!horizontal && !vertical) horizontal = true;
const [selected, setSelected] = useState(0);
const { isFocused } = useManagedFocus({
autoFocus,
id: focusId,
isActive: forceFocus === undefined,
});
useInput(
(input, key) => {
if (input === " " || key.return) {
buttons[selected].onClick();
} else if (
(vertical && key.downArrow) ||
(horizontal && key.rightArrow)
) {
setSelected((prev) => (prev + 1) % buttons.length);
} else if ((vertical && key.upArrow) || (horizontal && key.leftArrow)) {
setSelected((prev) => (prev - 1 + buttons.length) % buttons.length);
}
},
{ isActive: isFocused || forceFocus || false }
);
return (
<Box gap={1} {...props}>
{buttons.map((button, i) => (
<Text inverse={(isFocused || forceFocus) && i === selected}>
[{button.children}]
</Text>
))}
</Box>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Text } from "ink";
export function EllipsisText({
text,
maxLen,
fill = "",
}: {
text: string;
maxLen: number;
fill?: string;
}) {
if (text.length <= maxLen)
return (
<Text>
{text}
{fill && text.length < maxLen && (
<Text dimColor>
{fill.repeat(maxLen - text.length).slice(0, maxLen - text.length)}
</Text>
)}
</Text>
);
return (
<Text>
{text.slice(0, maxLen - 3)}
<Text dimColor>...</Text>
</Text>
);
}
+39
View File
@@ -0,0 +1,39 @@
import TextInput from "ink-text-input";
import { useManagedFocus } from "../../contexts/FocusManager.js";
export function Input({
focus,
value,
onChange,
onSubmit,
mask,
placeholder,
autoFocus,
focusId,
}: {
focus?: boolean;
value: string;
onChange: (value: string) => void;
onSubmit?: () => void;
mask?: string;
placeholder?: string;
autoFocus?: boolean;
focusId?: string;
}) {
const { isFocused } = useManagedFocus({
autoFocus,
id: focusId,
isActive: typeof focus !== "boolean",
});
return (
<TextInput
showCursor
focus={typeof focus === "boolean" ? focus : isFocused}
value={value}
onChange={onChange}
onSubmit={onSubmit}
mask={mask}
placeholder={placeholder}
/>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { Box, Text, useInput } from "ink";
import type { ReactNode } from "react";
import { useManagedFocus } from "../../contexts/FocusManager.js";
function Item({
text,
focused,
checked,
}: {
text: string;
focused: boolean;
checked: boolean;
}) {
return (
<Text inverse={checked} dimColor={!focused && !checked}>
{text}
</Text>
);
}
export function ListBox<T extends string>({
title,
items,
display,
selected,
setSelected,
autoFocus,
focusId,
width,
height,
}: {
title?: ReactNode | string;
items: T[];
display?: Record<T, string>;
selected: T;
setSelected: (item: T) => void;
autoFocus?: boolean;
focusId?: string;
width?: number;
height?: number;
}) {
const { isFocused } = useManagedFocus({
autoFocus,
id: focusId,
});
useInput(
(_, key) => {
if (key.downArrow) {
setSelected(items[(items.indexOf(selected) + 1) % items.length]);
} else if (key.upArrow) {
setSelected(
items[(items.indexOf(selected) - 1 + items.length) % items.length]
);
} else if (key.pageUp) {
setSelected(items[0]);
} else if (key.pageDown) {
setSelected(items[items.length - 1]);
}
},
{ isActive: isFocused }
);
return (
<Box
borderStyle={"round"}
flexDirection="column"
borderDimColor={!isFocused}
width={width}
height={height}
>
{title && (
<Box
borderStyle={"single"}
borderTop={false}
borderLeft={false}
borderRight={false}
borderDimColor={!isFocused}
>
{typeof title === "string" ? (
<Text bold color="cyan">
{title}
</Text>
) : (
title
)}
</Box>
)}
{items.map((item, i) => (
<Item
key={item}
focused={isFocused}
checked={i === items.indexOf(selected)}
text={display?.[item] ?? item}
/>
))}
</Box>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { Box, Spacer, Text, useInput } from "ink";
import {
useEffect,
useMemo,
useState,
type ComponentType,
type ReactNode,
} from "react";
export function ListDisplay<T>({
items,
Component,
height,
isFocused,
status,
onSelect,
}: {
items: T[];
Component: ComponentType<{ item: T; selected: boolean }>;
height: number;
isFocused?: boolean;
status?: ReactNode;
onSelect?: (item: T) => void;
}) {
const [start, setStart] = useState(0);
const slice = Math.min(start + height - 1, items.length);
const [selected, setSelected] = useState(0);
useInput(
(input, key) => {
if (key.upArrow) {
setSelected((prev) => (prev > 0 ? prev - 1 : items.length - 1));
} else if (key.downArrow) {
setSelected((prev) => (prev + 1) % items.length);
} else if (input === " " || key.return) {
onSelect?.(items[selected]);
}
},
{ isActive: isFocused }
);
useEffect(() => {
if (selected < start) setStart(selected);
else if (selected >= slice)
setStart(
Math.max(Math.min(selected - slice + start + 1, items.length - 1), 0)
);
}, [selected, start, slice, items.length]);
useEffect(() => {
if (selected < 0) setSelected(Math.max(items.length - 1, 0));
if (selected >= items.length) setSelected(0);
}, [selected, items.length]);
useEffect(() => {
setSelected(0);
}, [items]);
const indexed = useMemo(
() => items.map((item, index) => ({ item, index })),
[items]
);
return (
<Box flexDirection="column">
<Box flexDirection="column">
{indexed.slice(start, slice).map(({ item, index }) => (
<Component
key={index}
item={item}
selected={selected === index && isFocused !== false}
/>
))}
{items.length < height - 1 && (
<Box height={height - 1 - items.length} />
)}
</Box>
<Box justifyContent="space-between">
{status}
<Spacer />
<Text color="magenta">
{items.length ? (
<>
{start + 1}-{slice}/{items.length}
</>
) : (
<>(empty)</>
)}
</Text>
</Box>
</Box>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Box, useInput } from "ink";
import { use, type ReactNode } from "react";
import { BgColorContext } from "../../contexts/BgColor.js";
import { PopupContext, usePopup } from "../../contexts/FocusManager.js";
export function Popup({
children,
onClose,
}: {
children: ReactNode;
onClose?: () => void;
}) {
const { id, active } = usePopup();
useInput(
(_, key) => {
if (key.escape) onClose?.();
},
{ isActive: active && !!onClose }
);
return (
<PopupContext value={id}>
<Box
position="absolute"
alignSelf="center"
flexDirection="row"
alignItems="center"
justifyContent="center"
flexGrow={1}
width={"100%"}
>
<Box
borderStyle={active ? "double" : "bold"}
backgroundColor={use(BgColorContext)}
>
{children}
</Box>
</Box>
</PopupContext>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { useMemo, useState, type ComponentType } from "react";
import type { UseLoadResult } from "../../../react/hooks/useLoad.js";
import { ButtonList, type ButtonListItem } from "./Button.js";
import { useAfterRender } from "../../hooks/useAfterRender.js";
import { useManagedFocus } from "../../contexts/FocusManager.js";
import { usePanelSize } from "../../hooks/size.js";
import { Box, Text, useInput } from "ink";
import { Input } from "./Input.js";
import { ListDisplay } from "./ListDisplay.js";
export function SearchPanel<T>({
sub = false,
status,
refresh,
items,
match,
sort,
onSelect,
buttons,
ItemComponent,
height: forceHeight,
}: {
sub?: boolean;
status: UseLoadResult["status"];
refresh?: UseLoadResult["refresh"];
items: T[] | Record<string, T>;
match?: (filter: string, item: T) => boolean;
sort?: (a: T, b: T) => number;
onSelect?: (item: T) => void;
buttons?: ButtonListItem[];
ItemComponent: ComponentType<{
item: T;
selected: boolean;
}>;
height?: number;
}) {
const [filter, setFilter] = useState("");
const orderedItems = useMemo(() => {
let ordered = Array.isArray(items) ? items : Object.values(items);
if (match) ordered = ordered.filter((x) => !filter || match(filter, x));
if (sort) ordered.sort(sort);
return ordered;
}, [items, filter, match, sort]);
const afterRender = useAfterRender();
const { isFocused } = useManagedFocus();
const { height } = usePanelSize();
useInput(
(input, _) => {
if (input === "r") {
refresh?.();
}
},
{ isActive: isFocused && !!refresh }
);
const topbar = !!match || !!buttons?.length;
return (
<Box
borderStyle={sub ? undefined : "round"}
borderDimColor={!isFocused}
flexGrow={1}
flexDirection="column"
justifyContent="space-between"
>
{topbar && (
<Box justifyContent="space-between">
{!!match && (
<Text>
<Text color="yellow">Search: </Text>
<Input
value={filter}
onChange={setFilter}
onSubmit={() => {
if (orderedItems.length === 1) onSelect?.(orderedItems[0]);
}}
/>
</Text>
)}
{afterRender && !!buttons?.length && <ButtonList buttons={buttons} />}
</Box>
)}
<ListDisplay
items={orderedItems}
height={(forceHeight ?? height) - (topbar ? 3 : 2)}
Component={ItemComponent}
status={<Text dimColor>{status}</Text>}
isFocused={isFocused}
onSelect={onSelect}
/>
</Box>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
import { useDataUserById } from "../../../react/hooks/data/users.js";
import { Text } from "ink";
export function UserName({
uid,
...props
}: { uid: string } & ComponentProps<typeof Text>) {
const { status, user } = useDataUserById(uid);
return user ? (
<Text {...props}>{user.name}</Text>
) : (
<Text dimColor>{status}</Text>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "react";
export const BgColorContext = createContext<string>("black");
BgColorContext.displayName = "BgColorContext";
+66
View File
@@ -0,0 +1,66 @@
import { useFocus } from "ink";
import {
createContext,
use,
useEffect,
useId,
useState,
type ReactNode,
} from "react";
const FocusManagerWriteContext = createContext<
((fn: (prev: string[]) => string[]) => void) | null
>(null);
FocusManagerWriteContext.displayName = "FocusManagerWriteContext";
const FocusManagerReadContext = createContext<string[]>([]);
FocusManagerReadContext.displayName = "FocusManagerReadContext";
export const PopupContext = createContext<string | null>(null);
PopupContext.displayName = "PopupContext";
export function FocusManager({ children }: { children: ReactNode }) {
const [popupStack, setPopupStack] = useState<string[]>([]);
return (
<FocusManagerWriteContext value={setPopupStack}>
<FocusManagerReadContext value={popupStack}>
{children}
</FocusManagerReadContext>
</FocusManagerWriteContext>
);
}
export function usePopup() {
const id = useId();
const setPopupStack = use(FocusManagerWriteContext)!;
const popupStack = use(FocusManagerReadContext);
useEffect(() => {
setPopupStack((prev) => [...prev, id]);
return () => setPopupStack((prev) => prev.filter((x) => x !== id));
}, [id]);
return { id, active: id === (popupStack.at(-1) ?? null) };
}
export function useActive() {
const id = use(PopupContext);
const popupStack = use(FocusManagerReadContext);
return id === (popupStack.at(-1) ?? null);
}
export function useManagedFocus({
isActive = true,
autoFocus = false,
id,
}: Parameters<typeof useFocus>[0] = {}): ReturnType<typeof useFocus> {
const active = useActive();
const computedId = useId();
id ??= computedId;
const { focus, isFocused } = useFocus({
isActive: active && isActive,
autoFocus: active && autoFocus,
id,
});
useEffect(() => {
if (active && autoFocus) focus(id);
}, [active, autoFocus, id]);
return { focus, isFocused: isFocused && active };
}
+17
View File
@@ -0,0 +1,17 @@
import { useScreenSize } from "fullscreen-ink";
export function usePanelSize() {
const { width, height } = useScreenSize();
return {
width: width - 13,
height: height - 4,
};
}
export function useFreeSize() {
const { width, height } = useScreenSize();
return {
width,
height: height - 4,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { useEffect, useState } from "react";
export function useAfterRender(): boolean {
const [afterRender, setAfterRender] = useState(false);
useEffect(() => {
setAfterRender(true);
}, []);
return afterRender;
}