feat: initial commit
This commit is contained in:
+111
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const BgColorContext = createContext<string>("black");
|
||||
BgColorContext.displayName = "BgColorContext";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useAfterRender(): boolean {
|
||||
const [afterRender, setAfterRender] = useState(false);
|
||||
useEffect(() => {
|
||||
setAfterRender(true);
|
||||
}, []);
|
||||
return afterRender;
|
||||
}
|
||||
Reference in New Issue
Block a user