prompts can be added to prompt composer.
This commit is contained in:
parent
b0c70f6d17
commit
d9c1282d99
@ -27,6 +27,7 @@
|
||||
"react-redux": "^9.1.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"uuid": "^9.0.1",
|
||||
"vite": "^5.1.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@ -57,11 +58,12 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"jest-without-globals": "^0.0.3"
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"!node_modules/"
|
||||
]
|
||||
"transformIgnorePatterns": [
|
||||
"!node_modules/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -80,6 +80,9 @@ dependencies:
|
||||
typescript:
|
||||
specifier: ^4.9.5
|
||||
version: 4.9.5
|
||||
uuid:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
vite:
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(@types/node@16.18.82)
|
||||
@ -94,6 +97,9 @@ devDependencies:
|
||||
'@types/jest':
|
||||
specifier: ^27.5.2
|
||||
version: 27.5.2
|
||||
'@types/uuid':
|
||||
specifier: ^9.0.8
|
||||
version: 9.0.8
|
||||
jest-without-globals:
|
||||
specifier: ^0.0.3
|
||||
version: 0.0.3(jest@25.5.4)
|
||||
@ -3816,6 +3822,10 @@ packages:
|
||||
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
|
||||
dev: false
|
||||
|
||||
/@types/uuid@9.0.8:
|
||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||
dev: true
|
||||
|
||||
/@types/ws@8.5.10:
|
||||
resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
|
||||
dependencies:
|
||||
@ -12908,6 +12918,11 @@ packages:
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/v8-to-istanbul@4.1.4:
|
||||
resolution: {integrity: sha512-Rw6vJHj1mbdK8edjR7+zuJrpDtKIgNdAvTSAcpYfgMIw+u2dPDntD3dgN4XQFLU2/fvFQdzj+EeSGfd/jnY5fQ==}
|
||||
engines: {node: 8.x.x || >=10.10.0}
|
||||
|
@ -3,6 +3,7 @@ import './App.css';
|
||||
import PromptComposer from './components/PromptComposer';
|
||||
import { Box, Tabs, Tab, Typography } from '@material-ui/core';
|
||||
import { $textComposition } from './lib/prompt';
|
||||
import { TextPrompt } from './components/TextPrompt';
|
||||
|
||||
|
||||
interface TabPanelProps {
|
||||
@ -56,9 +57,7 @@ function App() {
|
||||
<PromptComposer />
|
||||
</CustomTabPanel>
|
||||
<CustomTabPanel value={value} index={1}>
|
||||
<div>
|
||||
<textarea aria-label='text-area'>{ $textComposition.get() }</textarea>
|
||||
</div>
|
||||
<TextPrompt />
|
||||
</CustomTabPanel>
|
||||
</>
|
||||
);
|
||||
|
@ -1,5 +0,0 @@
|
||||
import { Component, ReactComponentElement, ReactInstance, ReactNode } from "react";
|
||||
|
||||
export type Composable = {
|
||||
|
||||
} & ReactNode
|
@ -1,4 +1,3 @@
|
||||
import { Composable } from "./IComposable";
|
||||
import { LibraryItem as LibItem } from "../lib/prompt";
|
||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||
import { Button } from "@material-ui/core";
|
||||
@ -23,5 +22,5 @@ export function LibraryItem(props: StyleProps) {
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
) as Composable;
|
||||
);
|
||||
};
|
77
src/components/NewLibraryItem.test.tsx
Normal file
77
src/components/NewLibraryItem.test.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import {NewLibraryItem} from './NewLibraryItem';
|
||||
import { Category, addItemToLibrary, categoryHasName } from '../lib/prompt';
|
||||
|
||||
jest.mock('../lib/prompt', () => ({
|
||||
Category: Category,
|
||||
addItemToLibrary: jest.fn(),
|
||||
categoryHasName: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('NewLibraryItem', () => {
|
||||
beforeEach(() => {
|
||||
(addItemToLibrary as jest.Mock).mockReset();
|
||||
(categoryHasName as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
it('renders the form', () => {
|
||||
render(<NewLibraryItem />);
|
||||
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
expect(screen.getByText('Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText('Prompt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes the category', () => {
|
||||
render(<NewLibraryItem />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Prompt Item Category'), {
|
||||
target: { value: Category.vibes },
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Prompt Item Name')).toBeVisible();
|
||||
});
|
||||
|
||||
it('changes the name', () => {
|
||||
render(<NewLibraryItem />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes the prompt', () => {
|
||||
render(<NewLibraryItem />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Prompt'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a new library item', () => {
|
||||
render(<NewLibraryItem />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Category'), {
|
||||
target: { value: Category.subject },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Prompt'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Create'));
|
||||
|
||||
expect(addItemToLibrary).toHaveBeenCalledWith({
|
||||
category: Category.subject,
|
||||
name: 'Test',
|
||||
prompt: 'Test',
|
||||
});
|
||||
});
|
||||
});
|
81
src/components/NewLibraryItem.tsx
Normal file
81
src/components/NewLibraryItem.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Button, FormControl, InputLabel, MenuItem, TextField } from "@material-ui/core";
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import { Category, LibraryItem, addItemToLibrary, categoryHasName } from "../lib/prompt";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
import {v4 as uuidv4} from "uuid"
|
||||
|
||||
export interface NewLibraryItemProps {
|
||||
onNewCreated?: () => void;
|
||||
}
|
||||
|
||||
export function NewLibraryItem(props: NewLibraryItemProps) {
|
||||
const { onNewCreated } = props;
|
||||
|
||||
const [category, setCategory] = useState(Category.subject);
|
||||
const [name, setName] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
const handleCategoryChange = (e: any | SelectChangeEvent) => {
|
||||
setCategory(e.target.value as Category);
|
||||
}
|
||||
const handleNameChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setName(e.target.value);
|
||||
}
|
||||
const handlePromptChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setPrompt(e.target.value);
|
||||
}
|
||||
|
||||
const titleCase = (s: string) => {
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
const handleCreateItem = () => {
|
||||
const libraryItem = {
|
||||
id: uuidv4(),
|
||||
category,
|
||||
name: categoryHasName(category) ? name : null,
|
||||
prompt,
|
||||
} as LibraryItem;
|
||||
addItemToLibrary(libraryItem);
|
||||
setCategory(Category.subject);
|
||||
setName("");
|
||||
setPrompt("");
|
||||
onNewCreated ? onNewCreated() : null;
|
||||
}
|
||||
|
||||
const catChoices = Object.keys(Category);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<FormControl>
|
||||
<InputLabel htmlFor="new-prompt-category">Category</InputLabel>
|
||||
<Select
|
||||
native
|
||||
id="new-prompt-category"
|
||||
aria-label="Prompt Item Category"
|
||||
value={category}
|
||||
onChange={(e) => handleCategoryChange(e)}
|
||||
>
|
||||
{catChoices.map(cat => (
|
||||
<option value={cat} id={cat} key={cat}>{titleCase(cat)}</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl>
|
||||
{categoryHasName(category) ? (<InputLabel htmlFor="name">Name</InputLabel>) : <></>}
|
||||
{categoryHasName(category) ? (<TextField aria-label="Prompt Item Name" value={name} onChange={handleNameChange} id="name" />) : <></>}
|
||||
</FormControl>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl>
|
||||
<InputLabel htmlFor="prompt">Prompt</InputLabel>
|
||||
<TextField aria-label="Prompt Item Text" value={prompt} onChange={handlePromptChange} id="prompt" />
|
||||
<Button onClick={handleCreateItem} >Create</Button>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -2,7 +2,6 @@ import { Button, ButtonGroup, Chip, Divider } from '@material-ui/core';
|
||||
import React, { Component, useState } from 'react';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import {Composable} from "./IComposable"
|
||||
|
||||
import "./Nugget.css";
|
||||
import { Nugget as NuggetType, decreaseNuggetScore, increaseNuggetScore } from '../lib/prompt';
|
||||
@ -32,5 +31,5 @@ export default function Nugget(props : NuggetProps) {
|
||||
</ButtonGroup>
|
||||
</span>
|
||||
</div>
|
||||
) as Composable;
|
||||
);
|
||||
}
|
@ -2,8 +2,7 @@ import { Menu, MenuItem } from "@material-ui/core";
|
||||
import React, { Children, ReactNode } from 'react';
|
||||
import "./Operation.css";
|
||||
import { Op } from "../lib/operator";
|
||||
import { randomUUID } from "crypto";
|
||||
import { Composable } from "./IComposable";
|
||||
import { v4 as randomUUID } from "uuid";
|
||||
import { Operation as OperationType, changeOperationOp } from "../lib/prompt";
|
||||
import Nugget from "./Nugget";
|
||||
|
||||
@ -72,7 +71,7 @@ function Operation(props : OperationProps) {
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
) as Composable;
|
||||
);
|
||||
}
|
||||
|
||||
export { Operation, Op };
|
@ -1,27 +1,29 @@
|
||||
import { Button } from '@material-ui/core';
|
||||
import Masonry from '@mui/lab/Masonry';
|
||||
import Nugget from './Nugget';
|
||||
import { Operation } from './Operation';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import "./PromptArea.css";
|
||||
import "./PromptComposer.css";
|
||||
import { PromptLibrary } from './PromptLibrary';
|
||||
import React from 'react';
|
||||
import { $composition, $slottedComposition, LibraryItem, insertIntoComposition } from '../lib/prompt';
|
||||
import React, { useState } from 'react';
|
||||
import { $slottedComposition, LibraryItem, PromptItem, insertIntoComposition } from '../lib/prompt';
|
||||
import { Category } from '@mui/icons-material';
|
||||
import { useStore } from '@nanostores/react'
|
||||
import Nugget from './Nugget';
|
||||
import { Stack } from '@mui/material';
|
||||
import { Operation } from './Operation';
|
||||
|
||||
type Composable = (typeof Nugget) | (typeof Operation);
|
||||
export interface PromptComposerProps {
|
||||
|
||||
export default function PromptComposer(props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
}
|
||||
|
||||
export default function PromptComposer(props: PromptComposerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClickOpen = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = (value: string) => {
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
// setSelectedValue(value);
|
||||
};
|
||||
|
||||
const handleOnInsertItem = (item: LibraryItem) => {
|
||||
@ -30,19 +32,31 @@ export default function PromptComposer(props) {
|
||||
|
||||
const slottedComposition = useStore($slottedComposition);
|
||||
|
||||
const promptItemFactory = (promptItem : PromptItem, key : string) => {
|
||||
return "op" in promptItem ? <Operation operation={promptItem} key={key} /> : <Nugget nugget={promptItem} key={key} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button className="add-button">
|
||||
<AddIcon />
|
||||
<PromptLibrary
|
||||
open={open}
|
||||
onInsertItem={handleOnInsertItem}
|
||||
></PromptLibrary>
|
||||
|
||||
</Button>
|
||||
<Masonry columns={Object.keys(Category).length} spacing={2} sequential>
|
||||
<div>Something</div>
|
||||
</Masonry>
|
||||
<div>
|
||||
<Button className="add-button" onClick={handleClickOpen}>
|
||||
<AddIcon />
|
||||
<PromptLibrary
|
||||
open={open}
|
||||
onInsertItem={handleOnInsertItem}
|
||||
onClose={handleClose}
|
||||
></PromptLibrary>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{
|
||||
slottedComposition.map((itemCol, i) => (
|
||||
<Stack>
|
||||
{itemCol.map((promptItem, j) => promptItemFactory(promptItem, `item-${j}-${j}`))}
|
||||
</Stack>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
3
src/components/PromptLibrary.css
Normal file
3
src/components/PromptLibrary.css
Normal file
@ -0,0 +1,3 @@
|
||||
.prompt-library-dialog .categories div {
|
||||
display: inline;
|
||||
}
|
@ -31,6 +31,7 @@ const mockOpen: boolean = true;
|
||||
const mockProps: SimpleDialogProps = {
|
||||
open: mockOpen,
|
||||
onInsertItem: mockOnAddItem,
|
||||
onClose: mockOnClose,
|
||||
};
|
||||
|
||||
const mockLibrary: LibraryType = [
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { Checkbox, Dialog, DialogTitle } from "@material-ui/core";
|
||||
import { Button, Checkbox, Dialog, DialogTitle } from "@material-ui/core";
|
||||
import { LibraryItem as LibItemType, $library, Category } from "../lib/prompt";
|
||||
import { LibraryItem } from "./LibraryItem";
|
||||
import { ChangeEvent } from "react";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { ExitToAppOutlined } from "@mui/icons-material";
|
||||
import { NewLibraryItem } from "./NewLibraryItem";
|
||||
import "./PromptLibrary.css"
|
||||
|
||||
export interface SimpleDialogProps {
|
||||
open: boolean;
|
||||
// onClose: (composable: Composable) => void,
|
||||
onClose: () => void,
|
||||
// onAddItem: (item: LibItemType) => void,
|
||||
onInsertItem: (item: LibItemType) => void,
|
||||
}
|
||||
@ -25,10 +28,14 @@ const show = ($el: Element) => {
|
||||
}
|
||||
|
||||
export function PromptLibrary(props: SimpleDialogProps) {
|
||||
const { open, onInsertItem } = props;
|
||||
const { open, onInsertItem, onClose } = props;
|
||||
|
||||
const library = useStore($library);
|
||||
|
||||
const [doCreate, setDoCreate] = useState(false);
|
||||
|
||||
const [visibleCategories, setVisibleCategories] = useState([] as Category[]);
|
||||
|
||||
const handleOnAddItem = (item: LibItemType) => {
|
||||
// onAddItem(item);
|
||||
}
|
||||
@ -37,32 +44,47 @@ export function PromptLibrary(props: SimpleDialogProps) {
|
||||
onInsertItem(item);
|
||||
}
|
||||
|
||||
const filterCat = (catKey: string, catVal: string, v: ChangeEvent<HTMLInputElement>) => {
|
||||
const filterCat = (catKey: string, v: ChangeEvent<HTMLInputElement>) => {
|
||||
const isChecked = v.target.value === '1';
|
||||
document.querySelectorAll(`.category-${catVal}`).forEach($el => {
|
||||
setVisibleCategories((visibleCategories.includes(catKey as Category) && !isChecked) ? visibleCategories.filter(c => c != catKey) : [...visibleCategories, catKey as Category]);
|
||||
document.querySelectorAll(`.category-${catKey}`).forEach($el => {
|
||||
if (isChecked) show($el)
|
||||
else hide($el)
|
||||
});
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
}
|
||||
|
||||
const handleOnNewCreated = () => {
|
||||
setDoCreate(false);
|
||||
}
|
||||
|
||||
const categoryChoices = Object.keys(Category);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<Dialog className="prompt-library-dialog" onClose={handleClose} open={open}>
|
||||
<DialogTitle>Prompt Library</DialogTitle>
|
||||
<div className="categories">
|
||||
{Object.entries(Category).map(([catKey, catVal]) => {
|
||||
{categoryChoices.map(catKey => {
|
||||
return (
|
||||
<div>
|
||||
<Checkbox onChange={v => filterCat(catKey, catVal, v)} />
|
||||
<span>{title(catVal)}</span>
|
||||
<div key={catKey}>
|
||||
<Checkbox onChange={v => filterCat(catKey, v)} />
|
||||
<span>{title(catKey)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{
|
||||
library?.map(item => <LibraryItem item={item} onInsertItem={handleOnInsertItem} />)
|
||||
library?.map(item => <LibraryItem key={item.id} item={item} onInsertItem={handleOnInsertItem} />)
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={() => setDoCreate(true)}>Create</Button>
|
||||
</div>
|
||||
{doCreate ? (<NewLibraryItem onNewCreated={handleOnNewCreated} />) : (<></>)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
6
src/components/TextPrompt.css
Normal file
6
src/components/TextPrompt.css
Normal file
@ -0,0 +1,6 @@
|
||||
.text-prompt {
|
||||
width: 75%;
|
||||
height: 75%;
|
||||
padding: 10pt;
|
||||
margin: 4pt;
|
||||
}
|
11
src/components/TextPrompt.tsx
Normal file
11
src/components/TextPrompt.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { TextareaAutosize } from "@material-ui/core";
|
||||
import { $textComposition } from "../lib/prompt";
|
||||
import "./TextPrompt.css";
|
||||
import { useStore } from "@nanostores/react";
|
||||
|
||||
export function TextPrompt () {
|
||||
const text = useStore($textComposition);
|
||||
return (
|
||||
<TextareaAutosize content={text} className="text-prompt" />
|
||||
)
|
||||
}
|
@ -3,17 +3,13 @@ import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { Provider } from 'react-redux';
|
||||
import store from "./store"
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider >
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { v4 as randomUUID } from "uuid";
|
||||
import { Op } from "./operator";
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { v4 as randomUUID } from "uuid";
|
||||
import { Op } from "./operator";
|
||||
import {
|
||||
Library as LibraryType,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { v4 as randomUUID } from "uuid";
|
||||
import { Op } from "./operator";
|
||||
import { atom, computed } from "nanostores";
|
||||
|
||||
@ -8,6 +8,7 @@ type IdAble = {
|
||||
id: Id,
|
||||
}
|
||||
|
||||
// only vibes and styles will have a name.
|
||||
export enum Category {
|
||||
subject = "subject",
|
||||
style = "style",
|
||||
@ -15,6 +16,10 @@ export enum Category {
|
||||
medium = "medium",
|
||||
}
|
||||
|
||||
export function categoryHasName(cat : Category) {
|
||||
return (cat === Category.style || cat === Category.vibes)
|
||||
}
|
||||
|
||||
const N_CATEGORIES = Object.keys(Category).length;
|
||||
|
||||
export function catI(c: Category | string): number {
|
||||
|
Loading…
Reference in New Issue
Block a user