ui(admin): add "working" React admin ui

This commit is contained in:
Matthew Penner 2022-12-15 19:06:14 -07:00
parent d1c7494933
commit 5402584508
No known key found for this signature in database
199 changed files with 13387 additions and 151 deletions

View file

@ -11,7 +11,7 @@ export default ({ dark, className, children }: CodeProps) => (
<code
className={classNames('font-mono text-sm px-2 py-1 inline-block rounded', className, {
'bg-neutral-700': !dark,
'bg-neutral-900 text-gray-100': dark,
'bg-neutral-900 text-slate-100': dark,
})}
>
{children}

View file

@ -51,7 +51,7 @@ const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickP
<Portal>
<FadeTransition show duration="duration-250" key={copied ? 'visible' : 'invisible'}>
<div className="fixed z-50 bottom-0 right-0 m-4">
<div className="rounded-md py-3 px-4 text-gray-200 bg-neutral-600/95 shadow">
<div className="rounded-md py-3 px-4 text-slate-200 bg-neutral-600/95 shadow">
<p>
{showInNotification
? `Copied "${String(text)}" to clipboard.`

View file

@ -0,0 +1,318 @@
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
import { defaultKeymap, indentWithTab } from '@codemirror/commands';
import { commentKeymap } from '@codemirror/comment';
import { foldGutter, foldKeymap } from '@codemirror/fold';
import { lineNumbers, highlightActiveLineGutter } from '@codemirror/gutter';
import { defaultHighlightStyle } from '@codemirror/highlight';
import { history, historyKeymap } from '@codemirror/history';
import { indentOnInput, LanguageSupport, LRLanguage, indentUnit } from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import { bracketMatching } from '@codemirror/matchbrackets';
import { rectangularSelection } from '@codemirror/rectangular-selection';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { Compartment, Extension, EditorState } from '@codemirror/state';
import { StreamLanguage, StreamParser } from '@codemirror/stream-parser';
import { keymap, highlightSpecialChars, drawSelection, highlightActiveLine, EditorView } from '@codemirror/view';
import { clike } from '@codemirror/legacy-modes/mode/clike';
import { cpp } from '@codemirror/lang-cpp';
import { css } from '@codemirror/lang-css';
import { Cassandra, MariaSQL, MSSQL, MySQL, PostgreSQL, sql, SQLite, StandardSQL } from '@codemirror/lang-sql';
import { diff } from '@codemirror/legacy-modes/mode/diff';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { go } from '@codemirror/legacy-modes/mode/go';
import { html } from '@codemirror/lang-html';
import { http } from '@codemirror/legacy-modes/mode/http';
import { javascript, typescriptLanguage } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { lua } from '@codemirror/legacy-modes/mode/lua';
import { properties } from '@codemirror/legacy-modes/mode/properties';
import { python } from '@codemirror/legacy-modes/mode/python';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { rust } from '@codemirror/lang-rust';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { xml } from '@codemirror/lang-xml';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import React, { useCallback, useEffect, useState } from 'react';
import tw, { styled, TwStyle } from 'twin.macro';
import { ayuMirage } from '@/components/elements/EditorTheme';
type EditorMode = LanguageSupport | LRLanguage | StreamParser<unknown>;
export interface Mode {
name: string;
mime: string;
mimes?: string[];
mode?: EditorMode;
ext?: string[];
alias?: string[];
file?: RegExp;
}
export const modes: Mode[] = [
{ name: 'C', mime: 'text/x-csrc', mode: clike({}), ext: [ 'c', 'h', 'ino' ] },
{ name: 'C++', mime: 'text/x-c++src', mode: cpp(), ext: [ 'cpp', 'c++', 'cc', 'cxx', 'hpp', 'h++', 'hh', 'hxx' ], alias: [ 'cpp' ] },
{ name: 'C#', mime: 'text/x-csharp', mode: clike({}), ext: [ 'cs' ], alias: [ 'csharp', 'cs' ] },
{ name: 'CSS', mime: 'text/css', mode: css(), ext: [ 'css' ] },
{ name: 'CQL', mime: 'text/x-cassandra', mode: sql({ dialect: Cassandra }), ext: [ 'cql' ] },
{ name: 'Diff', mime: 'text/x-diff', mode: diff, ext: [ 'diff', 'patch' ] },
{ name: 'Dockerfile', mime: 'text/x-dockerfile', mode: dockerFile, file: /^Dockerfile$/ },
{ name: 'Git Markdown', mime: 'text/x-gfm', mode: markdown({ defaultCodeLanguage: markdownLanguage }), file: /^(readme|contributing|history|license).md$/i },
{ name: 'Golang', mime: 'text/x-go', mode: go, ext: [ 'go' ] },
{ name: 'HTML', mime: 'text/html', mode: html(), ext: [ 'html', 'htm', 'handlebars', 'hbs' ], alias: [ 'xhtml' ] },
{ name: 'HTTP', mime: 'message/http', mode: http },
{ name: 'JavaScript', mime: 'text/javascript', mimes: [ 'text/javascript', 'text/ecmascript', 'application/javascript', 'application/x-javascript', 'application/ecmascript' ], mode: javascript(), ext: [ 'js' ], alias: [ 'ecmascript', 'js', 'node' ] },
{ name: 'JSON', mime: 'application/json', mimes: [ 'application/json', 'application/x-json' ], mode: json(), ext: [ 'json', 'json5', 'map' ], alias: [ 'json5' ] },
{ name: 'Lua', mime: 'text/x-lua', mode: lua, ext: [ 'lua' ] },
{ name: 'Markdown', mime: 'text/x-markdown', mode: markdown({ defaultCodeLanguage: markdownLanguage }), ext: [ 'markdown', 'md', 'mkd' ] },
{ name: 'MariaDB', mime: 'text/x-mariadb', mode: sql({ dialect: MariaSQL }) },
{ name: 'MS SQL', mime: 'text/x-mssql', mode: sql({ dialect: MSSQL }) },
{ name: 'MySQL', mime: 'text/x-mysql', mode: sql({ dialect: MySQL }) },
{ name: 'Plain Text', mime: 'text/plain', mode: undefined, ext: [ 'txt', 'text', 'conf', 'def', 'list', 'log' ] },
{ name: 'PostgreSQL', mime: 'text/x-pgsql', mode: sql({ dialect: PostgreSQL }) },
{ name: 'Properties', mime: 'text/x-properties', mode: properties, ext: [ 'properties', 'ini', 'in' ], alias: [ 'ini', 'properties' ] },
{ name: 'Python', mime: 'text/x-python', mode: python, ext: [ 'BUILD', 'bzl', 'py', 'pyw' ], file: /^(BUCK|BUILD)$/ },
{ name: 'Ruby', mime: 'text/x-ruby', mode: ruby, ext: [ 'rb' ], alias: [ 'jruby', 'macruby', 'rake', 'rb', 'rbx' ] },
{ name: 'Rust', mime: 'text/x-rustsrc', mode: rust(), ext: [ 'rs' ] },
{ name: 'Sass', mime: 'text/x-sass', mode: css(), ext: [ 'sass' ] },
{ name: 'SCSS', mime: 'text/x-scss', mode: css(), ext: [ 'scss' ] },
{ name: 'Shell', mime: 'text/x-sh', mimes: [ 'text/x-sh', 'application/x-sh' ], mode: shell, ext: [ 'sh', 'ksh', 'bash' ], alias: [ 'bash', 'sh', 'zsh' ], file: /^PKGBUILD$/ },
{ name: 'SQL', mime: 'text/x-sql', mode: sql({ dialect: StandardSQL }), ext: [ 'sql' ] },
{ name: 'SQLite', mime: 'text/x-sqlite', mode: sql({ dialect: SQLite }) },
{ name: 'TOML', mime: 'text/x-toml', mode: toml, ext: [ 'toml' ] },
{ name: 'TypeScript', mime: 'application/typescript', mode: typescriptLanguage, ext: [ 'ts' ], alias: [ 'ts' ] },
{ name: 'XML', mime: 'application/xml', mimes: [ 'application/xml', 'text/xml' ], mode: xml(), ext: [ 'xml', 'xsl', 'xsd', 'svg' ], alias: [ 'rss', 'wsdl', 'xsd' ] },
{ name: 'YAML', mime: 'text/x-yaml', mimes: [ 'text/x-yaml', 'text/yaml' ], mode: yaml, ext: [ 'yaml', 'yml' ], alias: [ 'yml' ] },
];
export const modeToExtension = (m: EditorMode): Extension => {
if (m instanceof LanguageSupport) {
return m;
}
if (m instanceof LRLanguage) {
return m;
}
return StreamLanguage.define(m);
};
const findModeByFilename = (filename: string): Mode => {
for (let i = 0; i < modes.length; i++) {
const info = modes[i];
if (info.file && info.file.test(filename)) {
return info;
}
}
const dot = filename.lastIndexOf('.');
const ext = dot > -1 && filename.substring(dot + 1, filename.length);
if (ext) {
for (let i = 0; i < modes.length; i++) {
const info = modes[i];
if (info.ext) {
for (let j = 0; j < info.ext.length; j++) {
if (info.ext[j] === ext) {
return info;
}
}
}
}
}
const plainText = modes.find(m => m.mime === 'text/plain');
if (plainText === undefined) {
throw new Error('failed to find \'text/plain\' mode');
}
return plainText;
};
const findLanguageExtensionByMode = (mode: Mode): Extension => {
if (mode.mode === undefined) {
return [];
}
return modeToExtension(mode.mode);
};
const defaultExtensions: Extension = [
ayuMirage,
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
defaultHighlightStyle.fallback,
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...commentKeymap,
...completionKeymap,
...lintKeymap,
indentWithTab,
]),
EditorState.tabSize.of(4),
// This is gonna piss people off, but that isn't my problem.
indentUnit.of('\t'),
];
const EditorContainer = styled.div<{ overrides?: TwStyle }>`
//min-height: 12rem;
${tw`relative`};
& > div {
${props => props.overrides};
&.cm-focused {
outline: none;
}
}
`;
export interface Props {
className?: string;
style?: React.CSSProperties;
overrides?: TwStyle;
initialContent?: string;
extensions?: Extension[];
mode?: EditorMode;
filename?: string;
onModeChanged?: (mode: Mode) => void;
fetchContent?: (callback: () => Promise<string>) => void;
onContentSaved?: () => void;
}
export default ({ className, style, overrides, initialContent, extensions, mode, filename, onModeChanged, fetchContent, onContentSaved }: Props) => {
const [ languageConfig ] = useState<Compartment>(new Compartment());
const [ keybinds ] = useState<Compartment>(new Compartment());
const [ view, setView ] = useState<EditorView>();
const createEditorState = () => {
return EditorState.create({
doc: initialContent,
extensions: [
...defaultExtensions,
...(extensions !== undefined ? extensions : []),
languageConfig.of(mode !== undefined ? modeToExtension(mode) : findLanguageExtensionByMode(findModeByFilename(filename || ''))),
keybinds.of([]),
],
});
};
const ref = useCallback((node) => {
if (!node) {
return;
}
const view = new EditorView({
state: createEditorState(),
parent: node,
});
setView(view);
}, []);
// This useEffect is required to send the proper mode back to the parent element
// due to the initial language being set with EditorState#create, rather than in
// an useEffect like this one, or one watching `filename`.
useEffect(() => {
if (onModeChanged === undefined) {
return;
}
onModeChanged(findModeByFilename(filename || ''));
}, []);
useEffect(() => {
if (view === undefined) {
return;
}
if (mode === undefined) {
return;
}
view.dispatch({
effects: languageConfig.reconfigure(modeToExtension(mode)),
});
}, [ mode ]);
useEffect(() => {
if (view === undefined) {
return;
}
if (filename === undefined) {
return;
}
const mode = findModeByFilename(filename || '');
view.dispatch({
effects: languageConfig.reconfigure(findLanguageExtensionByMode(mode)),
});
if (onModeChanged !== undefined) {
onModeChanged(mode);
}
}, [ filename ]);
useEffect(() => {
if (view === undefined) {
return;
}
// We could dispatch a view update to replace the content, but this would keep the edit history,
// and previously would duplicate the content of the editor.
view.setState(createEditorState());
}, [ initialContent ]);
useEffect(() => {
if (fetchContent === undefined) {
return;
}
if (!view) {
fetchContent(() => Promise.reject(new Error('no editor session has been configured')));
return;
}
if (onContentSaved !== undefined) {
view.dispatch({
effects: keybinds.reconfigure(keymap.of([
{
key: 'Mod-s',
run: () => {
onContentSaved();
return true;
},
},
])),
});
}
fetchContent(() => Promise.resolve(view.state.doc.toString()));
}, [ view, fetchContent, onContentSaved ]);
return (
<EditorContainer className={className} style={style} overrides={overrides} ref={ref}/>
);
};

View file

@ -1,8 +1,12 @@
import type { FieldProps } from 'formik';
import { Field as FormikField } from 'formik';
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import * as React from 'react';
import { Field as FormikField, FieldProps } from 'formik';
import Input from '@/components/elements/Input';
import tw, { styled } from 'twin.macro';
import Label from '@/components/elements/Label';
import Input, { Textarea } from '@/components/elements/Input';
import InputError from '@/components/elements/InputError';
interface OwnProps {
name: string;
@ -12,7 +16,7 @@ interface OwnProps {
validate?: (value: any) => undefined | string | Promise<any>;
}
type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>;
type Props = OwnProps & Omit<InputHTMLAttributes<HTMLInputElement>, 'name'>;
const Field = forwardRef<HTMLInputElement, Props>(
({ id, name, light = false, label, description, validate, ...props }, ref) => (
@ -47,3 +51,42 @@ const Field = forwardRef<HTMLInputElement, Props>(
Field.displayName = 'Field';
export default Field;
type TextareaProps = OwnProps & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'name'>;
export const TextareaField = forwardRef<HTMLTextAreaElement, TextareaProps>(function TextareaField(
{ id, name, light = false, label, description, validate, className, ...props },
ref,
) {
return (
<FormikField innerRef={ref} name={name} validate={validate}>
{({ field, form: { errors, touched } }: FieldProps) => (
<div className={className}>
{label && (
<Label htmlFor={id} isLight={light}>
{label}
</Label>
)}
<Textarea
id={id}
{...field}
{...props}
isLight={light}
hasError={!!(touched[field.name] && errors[field.name])}
/>
<InputError errors={errors} touched={touched} name={field.name}>
{description || null}
</InputError>
</div>
)}
</FormikField>
);
});
TextareaField.displayName = 'TextareaField';
export const FieldRow = styled.div`
${tw`grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-6 mb-6`};
& > div {
${tw`sm:w-full sm:flex sm:flex-col`};
}
`;

View file

@ -0,0 +1,315 @@
import { debounce } from 'debounce';
import React, { createRef, ReactElement, useEffect, useState } from 'react';
import tw, { styled } from 'twin.macro';
import Input from '@/components/elements/Input';
import InputSpinner from '@/components/elements/InputSpinner';
import Label from '@/components/elements/Label';
const Dropdown = styled.div<{ expanded: boolean }>`
${tw`absolute z-10 w-full mt-1 rounded-md shadow-lg bg-neutral-900`};
${props => !props.expanded && tw`hidden`};
`;
interface OptionProps<T> {
selectId: string;
id: number;
item: T;
active: boolean;
isHighlighted?: boolean;
onClick?: (item: T) => (e: React.MouseEvent) => void;
children: React.ReactNode;
}
interface IdObj {
id: number;
}
export const Option = <T extends IdObj>({ selectId, id, item, active, isHighlighted, onClick, children }: OptionProps<T>) => {
if (isHighlighted === undefined) {
isHighlighted = false;
}
// This should never be true, but just in-case we set it to an empty function to make sure shit doesn't blow up.
if (onClick === undefined) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
onClick = () => () => {};
}
if (active) {
return (
<li id={selectId + '-select-item-' + id} role="option" css={[ tw`relative py-2 pl-3 cursor-pointer select-none text-neutral-200 pr-9 hover:bg-neutral-700`, isHighlighted ? tw`bg-neutral-700` : null ]} onClick={onClick(item)}>
<div css={tw`flex items-center`}>
<span css={tw`block font-medium truncate`}>
{children}
</span>
</div>
<span css={tw`absolute inset-y-0 right-0 flex items-center pr-4`}>
<svg css={tw`w-5 h-5 text-primary-400`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path clipRule="evenodd" fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"/>
</svg>
</span>
</li>
);
}
return (
<li id={selectId + 'select-item-' + id} role="option" css={[ tw`relative py-2 pl-3 cursor-pointer select-none text-neutral-200 pr-9 hover:bg-neutral-700`, isHighlighted ? tw`bg-neutral-700` : null ]} onClick={onClick(item)}>
<div css={tw`flex items-center`}>
<span css={tw`block font-normal truncate`}>
{children}
</span>
</div>
</li>
);
};
interface SearchableSelectProps<T> {
id: string;
name: string;
label: string;
placeholder?: string;
nullable?: boolean;
selected: T | null;
setSelected: (item: T | null) => void;
items: T[] | null;
setItems: (items: T[] | null) => void;
onSearch: (query: string) => Promise<void>;
onSelect: (item: T | null) => void;
getSelectedText: (item: T | null) => string | undefined;
children: React.ReactNode;
className?: string;
}
export const SearchableSelect = <T extends IdObj>({ id, name, label, placeholder, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children, className }: SearchableSelectProps<T>) => {
const [ loading, setLoading ] = useState(false);
const [ expanded, setExpanded ] = useState(false);
const [ inputText, setInputText ] = useState('');
const [ highlighted, setHighlighted ] = useState<number | null>(null);
const searchInput = createRef<HTMLInputElement>();
const itemsList = createRef<HTMLDivElement>();
const onFocus = () => {
setInputText('');
setItems(null);
setExpanded(true);
setHighlighted(null);
};
const onBlur = () => {
setInputText(getSelectedText(selected) || '');
setItems(null);
setExpanded(false);
setHighlighted(null);
};
const search = debounce((query: string) => {
if (!expanded) {
return;
}
if (query === '' || query.length < 2) {
setItems(null);
setHighlighted(null);
return;
}
setLoading(true);
onSearch(query).then(() => setLoading(false));
}, 250);
const handleInputKeydown = (e: React.KeyboardEvent) => {
if (e.key === 'Tab' || e.key === 'Escape') {
onBlur();
return;
}
if (!items) {
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Prevent up and down arrows from moving the cursor in the input.
e.preventDefault();
if (highlighted === null) {
setHighlighted(items[0].id);
return;
}
const item = items.find(i => i.id === highlighted);
if (!item) {
return;
}
let index = items.indexOf(item);
if (e.key === 'ArrowUp') {
if (--index < 0) {
return;
}
} else {
if (++index >= items.length) {
return;
}
}
setHighlighted(items[index].id);
return;
}
// Prevent the form from being submitted if the user accidentally hits enter
// while focused on the select.
if (e.key === 'Enter') {
e.preventDefault();
const item = items.find(i => i.id === highlighted);
if (!item) {
return;
}
setSelected(item);
onSelect(item);
}
};
useEffect(() => {
const clickHandler = (e: MouseEvent) => {
const input = searchInput.current;
const menu = itemsList.current;
if (e.button === 2 || !expanded || !input || !menu) {
return;
}
if (e.target === input || input.contains(e.target as Node)) {
return;
}
if (e.target === menu || menu.contains(e.target as Node)) {
return;
}
if (e.target === input || input.contains(e.target as Node)) {
return;
}
if (e.target === menu || menu.contains(e.target as Node)) {
return;
}
onBlur();
};
const contextmenuHandler = () => {
onBlur();
};
window.addEventListener('mousedown', clickHandler);
window.addEventListener('contextmenu', contextmenuHandler);
return () => {
window.removeEventListener('mousedown', clickHandler);
window.removeEventListener('contextmenu', contextmenuHandler);
};
}, [ expanded ]);
const onClick = (item: T) => () => {
onSelect(item);
setExpanded(false);
setInputText(getSelectedText(selected) || '');
};
useEffect(() => {
if (expanded) {
return;
}
setInputText(getSelectedText(selected) || '');
}, [ selected ]);
// This shit is really stupid but works, so is it really stupid?
const c = React.Children.map(children, child => React.cloneElement(child as ReactElement, {
isHighlighted: ((child as ReactElement).props as OptionProps<T>).id === highlighted,
onClick: onClick.bind(child),
}));
return (
<div className={className}>
<div css={tw`flex flex-row`}>
<Label htmlFor={id + '-select-label'}>{label}</Label>
</div>
<div css={tw`relative`}>
<InputSpinner visible={loading}>
<Input
ref={searchInput}
type={'search'}
id={id}
name={name}
value={inputText}
readOnly={!expanded}
onFocus={onFocus}
onChange={e => {
setInputText(e.currentTarget.value);
search(e.currentTarget.value);
}}
onKeyDown={handleInputKeydown}
className={'ignoreReadOnly'}
placeholder={placeholder}
/>
</InputSpinner>
<div css={[ tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3`, !expanded && tw`pointer-events-none` ]}>
{inputText !== '' && expanded &&
<svg css={tw`w-5 h-5 text-neutral-400 cursor-pointer`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
onMouseDown={e => {
e.preventDefault();
setInputText('');
}}
>
<path clipRule="evenodd" fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
</svg>
}
<svg css={tw`w-5 h-5 text-neutral-400 pointer-events-none`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path clipRule="evenodd" fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"/>
</svg>
</div>
<Dropdown ref={itemsList} expanded={expanded}>
{items === null || items.length < 1 ?
items === null || inputText.length < 2 ?
<div css={tw`flex flex-row items-center h-10 px-3`}>
<p css={tw`text-sm`}>Please type 2 or more characters.</p>
</div>
:
<div css={tw`flex flex-row items-center h-10 px-3`}>
<p css={tw`text-sm`}>No results found.</p>
</div>
:
<ul
tabIndex={-1}
role={id + '-select'}
aria-labelledby={id + '-select-label'}
aria-activedescendant={id + '-select-item-' + selected?.id}
css={tw`py-2 overflow-auto text-base rounded-md max-h-56 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm`}
>
{c}
</ul>
}
</Dropdown>
</div>
</div>
);
};
export default SearchableSelect;

View file

@ -0,0 +1,347 @@
import { CSSObject } from '@emotion/serialize';
import { Field as FormikField, FieldProps } from 'formik';
import React, { forwardRef } from 'react';
import Select, { ContainerProps, ControlProps, GroupProps, IndicatorContainerProps, IndicatorProps, InputProps, MenuListComponentProps, MenuProps, MultiValueProps, OptionProps, PlaceholderProps, SingleValueProps, StylesConfig, ValueContainerProps } from 'react-select';
import Async from 'react-select/async';
import Creatable from 'react-select/creatable';
import tw, { theme } from 'twin.macro';
import Label from '@/components/elements/Label';
import { ValueType } from 'react-select/src/types';
import { GroupHeadingProps } from 'react-select/src/components/Group';
import { MenuPortalProps, NoticeProps } from 'react-select/src/components/Menu';
import { LoadingIndicatorProps } from 'react-select/src/components/indicators';
import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
type T = any;
export const SelectStyle: StylesConfig<T, any, any> = {
clearIndicator: (base: CSSObject, props: IndicatorProps<T, any, any>): CSSObject => {
return {
...base,
color: props.isFocused ? theme`colors.neutral.300` : theme`colors.neutral.400`,
':hover': {
color: theme`colors.neutral.100`,
},
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
container: (base: CSSObject, props: ContainerProps<T, any, any>): CSSObject => {
return {
...base,
};
},
control: (base: CSSObject, props: ControlProps<T, any, any>): CSSObject => {
return {
...base,
height: '3rem',
background: theme`colors.neutral.600`,
borderColor: !props.isFocused ? theme`colors.neutral.500` : theme`colors.primary.300`,
borderWidth: '2px',
color: theme`colors.neutral.200`,
cursor: 'pointer',
boxShadow: props.isFocused ?
'rgb(255, 255, 255) 0px 0px 0px 0px, rgba(36, 135, 235, 0.5) 0px 0px 0px 2px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px'
:
undefined,
':hover': {
borderColor: !props.isFocused ? theme`colors.neutral.400` : theme`colors.primary.300`,
},
};
},
dropdownIndicator: (base: CSSObject, props: IndicatorProps<T, any, any>): CSSObject => {
return {
...base,
color: props.isFocused ? theme`colors.neutral.300` : theme`colors.neutral.400`,
transform: props.isFocused ? 'rotate(180deg)' : undefined,
':hover': {
color: theme`colors.neutral.300`,
},
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
group: (base: CSSObject, props: GroupProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
groupHeading: (base: CSSObject, props: GroupHeadingProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
indicatorsContainer: (base: CSSObject, props: IndicatorContainerProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
indicatorSeparator: (base: CSSObject, props: IndicatorProps<T, any, any>): CSSObject => {
return {
...base,
backgroundColor: theme`colors.neutral.500`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
input: (base: CSSObject, props: InputProps): CSSObject => {
return {
...base,
color: theme`colors.neutral.200`,
fontSize: '0.875rem',
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadingIndicator: (base: CSSObject, props: LoadingIndicatorProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadingMessage: (base: CSSObject, props: NoticeProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
menu: (base: CSSObject, props: MenuProps<T, any, any>): CSSObject => {
return {
...base,
background: theme`colors.neutral.900`,
color: theme`colors.neutral.200`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
menuList: (base: CSSObject, props: MenuListComponentProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
menuPortal: (base: CSSObject, props: MenuPortalProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multiValue: (base: CSSObject, props: MultiValueProps<T, any>): CSSObject => {
return {
...base,
background: theme`colors.neutral.900`,
color: theme`colors.neutral.200`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multiValueLabel: (base: CSSObject, props: MultiValueProps<T, any>): CSSObject => {
return {
...base,
color: theme`colors.neutral.200`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multiValueRemove: (base: CSSObject, props: MultiValueRemoveProps<T, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
noOptionsMessage: (base: CSSObject, props: NoticeProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
option: (base: CSSObject, props: OptionProps<T, any, any>): CSSObject => {
return {
...base,
background: theme`colors.neutral.900`,
':hover': {
background: theme`colors.neutral.700`,
cursor: 'pointer',
},
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
placeholder: (base: CSSObject, props: PlaceholderProps<T, any, any>): CSSObject => {
return {
...base,
color: theme`colors.neutral.300`,
fontSize: '0.875rem',
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
singleValue: (base: CSSObject, props: SingleValueProps<T, any>): CSSObject => {
return {
...base,
color: '#00000',
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
valueContainer: (base: CSSObject, props: ValueContainerProps<T, any>): CSSObject => {
return {
...base,
};
},
};
export interface Option {
value: string;
label: string;
}
interface SelectFieldProps {
id?: string;
name: string;
label?: string;
description?: string;
placeholder?: string;
validate?: (value: any) => undefined | string | Promise<any>;
options: Array<Option>;
isMulti?: boolean;
isSearchable?: boolean;
isCreatable?: boolean;
isValidNewOption?: ((
inputValue: string,
value: ValueType<any, boolean>,
options: ReadonlyArray<any>,
) => boolean) | undefined;
className?: string;
}
const SelectField = forwardRef<HTMLElement, SelectFieldProps>(
function Select2 ({ id, name, label, description, validate, className, isMulti, isCreatable, ...props }, ref) {
const { options } = props;
const onChange = (options: Option | Option[], name: string, setFieldValue: (field: string, value: any, shouldValidate?: boolean) => void) => {
if (isMulti) {
setFieldValue(name, (options as Option[]).map(o => o.value));
return;
}
setFieldValue(name, (options as Option).value);
};
return (
<FormikField innerRef={ref} name={name} validate={validate}>
{({ field, form: { errors, touched, setFieldValue } }: FieldProps) => (
<div className={className}>
{label && <Label htmlFor={id}>{label}</Label>}
{isCreatable ?
<Creatable
{...field}
{...props}
styles={SelectStyle}
options={options}
value={(options ? options.find(o => o.value === field.value) : '') as any}
onChange={o => onChange(o, name, setFieldValue)}
isMulti={isMulti}
/>
:
<Select
{...field}
{...props}
styles={SelectStyle}
options={options}
value={(options ? options.find(o => o.value === field.value) : '') as any}
onChange={o => onChange(o, name, setFieldValue)}
isMulti={isMulti}
/>
}
{touched[field.name] && errors[field.name] ?
<p css={tw`text-red-200 text-xs mt-1`}>
{(errors[field.name] as string).charAt(0).toUpperCase() + (errors[field.name] as string).slice(1)}
</p>
:
description ? <p css={tw`text-neutral-400 text-xs mt-1`}>{description}</p> : null
}
</div>
)}
</FormikField>
);
}
);
interface AsyncSelectFieldProps {
id?: string;
name: string;
label?: string;
description?: string;
placeholder?: string;
validate?: (value: any) => undefined | string | Promise<any>;
isMulti?: boolean;
className?: string;
loadOptions(inputValue: string, callback: (options: Array<Option>) => void): void;
}
const AsyncSelectField = forwardRef<HTMLElement, AsyncSelectFieldProps>(
function AsyncSelect2 ({ id, name, label, description, validate, className, isMulti, ...props }, ref) {
const onChange = (options: Option | Option[], name: string, setFieldValue: (field: string, value: any, shouldValidate?: boolean) => void) => {
if (isMulti) {
setFieldValue(name, (options as Option[]).map(o => Number(o.value)));
return;
}
setFieldValue(name, Number((options as Option).value));
};
return (
<FormikField innerRef={ref} name={name} validate={validate}>
{({ field, form: { errors, touched, setFieldValue } }: FieldProps) => (
<div className={className}>
{label && <Label htmlFor={id}>{label}</Label>}
<Async
{...props}
id={id}
name={name}
styles={SelectStyle}
onChange={o => onChange(o, name, setFieldValue)}
isMulti={isMulti}
/>
{touched[field.name] && errors[field.name] ?
<p css={tw`text-red-200 text-xs mt-1`}>
{(errors[field.name] as string).charAt(0).toUpperCase() + (errors[field.name] as string).slice(1)}
</p>
:
description ? <p css={tw`text-neutral-400 text-xs mt-1`}>{description}</p> : null
}
</div>
)}
</FormikField>
);
}
);
export default SelectField;
export { AsyncSelectField };

View file

@ -44,26 +44,26 @@ export default ({ activity, children }: Props) => {
const properties = wrapProperties(activity.properties);
return (
<div className={'grid grid-cols-10 py-4 border-b-2 border-gray-800 last:rounded-b last:border-0 group'}>
<div className={'grid grid-cols-10 py-4 border-b-2 border-slate-800 last:rounded-b last:border-0 group'}>
<div className={'hidden sm:flex sm:col-span-1 items-center justify-center select-none'}>
<div className={'flex items-center w-10 h-10 rounded-full bg-gray-600 overflow-hidden'}>
<div className={'flex items-center w-10 h-10 rounded-full bg-slate-600 overflow-hidden'}>
<Avatar name={actor?.uuid || 'system'} />
</div>
</div>
<div className={'col-span-10 sm:col-span-9 flex'}>
<div className={'flex-1 px-4 sm:px-0'}>
<div className={'flex items-center text-gray-50'}>
<div className={'flex items-center text-slate-50'}>
<Tooltip placement={'top'} content={actor?.email || 'System User'}>
<span>{actor?.username || 'System'}</span>
</Tooltip>
<span className={'text-gray-400'}>&nbsp;&mdash;&nbsp;</span>
<span className={'text-slate-400'}>&nbsp;&mdash;&nbsp;</span>
<Link
to={`#${pathTo({ event: activity.event })}`}
className={'transition-colors duration-75 active:text-cyan-400 hover:text-cyan-400'}
>
{activity.event}
</Link>
<div className={classNames(style.icons, 'group-hover:text-gray-300')}>
<div className={classNames(style.icons, 'group-hover:text-slate-300')}>
{activity.isApi && (
<Tooltip placement={'top'} content={'Using API Key'}>
<TerminalIcon />
@ -84,7 +84,7 @@ export default ({ activity, children }: Props) => {
{activity.ip && (
<span>
{activity.ip}
<span className={'text-gray-400'}>&nbsp;|&nbsp;</span>
<span className={'text-slate-400'}>&nbsp;|&nbsp;</span>
</span>
)}
<Tooltip placement={'right'} content={format(activity.timestamp, 'MMM do, yyyy H:mm:ss')}>

View file

@ -11,7 +11,7 @@ export default ({ meta }: { meta: Record<string, unknown> }) => {
<Dialog open={open} onClose={() => setOpen(false)} hideCloseIcon title={'Metadata'}>
<pre
className={
'bg-gray-900 rounded p-2 font-mono text-sm leading-relaxed overflow-x-scroll whitespace-pre-wrap'
'bg-slate-900 rounded p-2 font-mono text-sm leading-relaxed overflow-x-scroll whitespace-pre-wrap'
}
>
{JSON.stringify(meta, null, 2)}
@ -23,7 +23,7 @@ export default ({ meta }: { meta: Record<string, unknown> }) => {
<button
aria-describedby={'View additional event metadata'}
className={
'p-2 transition-colors duration-100 text-gray-400 group-hover:text-gray-300 group-hover:hover:text-gray-50'
'p-2 transition-colors duration-100 text-slate-400 group-hover:text-slate-300 group-hover:hover:text-slate-50'
}
onClick={() => setOpen(true)}
>

View file

@ -1,8 +1,8 @@
.icons {
@apply flex space-x-1 mx-2 transition-colors duration-100 text-gray-400;
@apply flex space-x-1 mx-2 transition-colors duration-100 text-slate-400;
& svg {
@apply px-1 py-px cursor-pointer hover:text-gray-50 h-5 w-auto;
@apply px-1 py-px cursor-pointer hover:text-slate-50 h-5 w-auto;
}
}
@ -10,6 +10,6 @@
@apply mt-1 text-sm break-words line-clamp-2 pr-4;
& strong {
@apply text-gray-50 font-semibold break-all;
@apply text-slate-50 font-semibold break-all;
}
}

View file

@ -12,7 +12,7 @@ export default ({ type, className, children }: AlertProps) => {
return (
<div
className={classNames(
'flex items-center border-l-8 text-gray-50 rounded-md shadow px-4 py-3',
'flex items-center border-l-8 text-slate-50 rounded-md shadow px-4 py-3',
{
['border-red-500 bg-red-500/25']: type === 'danger',
['border-yellow-500 bg-yellow-500/25']: type === 'warning',

View file

@ -1,7 +1,7 @@
.button {
@apply px-4 py-2 inline-flex items-center justify-center;
@apply rounded text-base font-semibold transition-all duration-100;
@apply focus:ring-[3px] focus:ring-offset-2 focus:ring-offset-gray-700 focus:ring-opacity-50;
@apply focus:ring-[3px] focus:ring-offset-2 focus:ring-offset-slate-700 focus:ring-opacity-50;
/* Sizing Controls */
&.small {
@ -13,7 +13,7 @@
}
&.secondary {
@apply text-gray-50 bg-transparent;
@apply text-slate-50 bg-transparent;
&:disabled {
background: transparent !important;
@ -47,20 +47,20 @@
}
.text {
@apply bg-gray-500 text-gray-50;
@apply hover:bg-gray-400 active:bg-gray-400 focus:ring-gray-300 focus:ring-opacity-50;
@apply bg-slate-500 text-slate-50;
@apply hover:bg-slate-400 active:bg-slate-400 focus:ring-slate-300 focus:ring-opacity-50;
&.secondary {
@apply hover:bg-gray-500 active:bg-gray-500;
@apply hover:bg-slate-500 active:bg-slate-500;
}
&:disabled {
@apply bg-gray-500/75 text-gray-200/75;
@apply bg-slate-500/75 text-slate-200/75;
}
}
.danger {
@apply bg-red-600 text-gray-50;
@apply bg-red-600 text-slate-50;
@apply hover:bg-red-500 active:bg-red-500 focus:ring-red-400 focus:ring-opacity-75;
&.secondary {

View file

@ -73,7 +73,7 @@ export default ({
open={open}
onClose={onDialogClose}
>
<div className={'fixed inset-0 bg-gray-900/50 z-40'} />
<div className={'fixed inset-0 bg-slate-900/50 z-40'} />
<div className={'fixed inset-0 overflow-y-auto z-50'}>
<div
ref={container}

View file

@ -8,7 +8,9 @@ export default ({ children }: { children: React.ReactNode }) => {
useDeepCompareEffect(() => {
setFooter(
<div className={'px-6 py-3 bg-gray-700 flex items-center justify-end space-x-3 rounded-b'}>{children}</div>,
<div className={'px-6 py-3 bg-slate-700 flex items-center justify-end space-x-3 rounded-b'}>
{children}
</div>,
);
}, [children]);

View file

@ -3,12 +3,12 @@
}
.panel {
@apply relative bg-gray-600 rounded max-w-xl w-full mx-auto shadow-lg text-left;
@apply ring-4 ring-gray-800 ring-opacity-80;
@apply relative bg-slate-600 rounded max-w-xl w-full mx-auto shadow-lg text-left;
@apply ring-4 ring-slate-800 ring-opacity-80;
}
.title {
@apply font-header text-xl font-medium mb-2 text-gray-50 pr-4;
@apply font-header text-xl font-medium mb-2 text-slate-50 pr-4;
}
.close_icon {

View file

@ -0,0 +1,23 @@
import React from 'react';
import { PaginationDataSet } from '@/api/http';
const TFootPaginated = ({ pagination, span }: { span: number; pagination: PaginationDataSet }) => {
const start = (pagination.currentPage - 1) * pagination.perPage;
const end = ((pagination.currentPage - 1) * pagination.perPage) + pagination.count;
return (
<tfoot>
<tr className={'bg-neutral-800'}>
<td scope={'col'} colSpan={span} className={'px-4 py-2'}>
<p className={'text-sm text-neutral-500'}>
Showing <span className={'font-semibold text-neutral-400'}>{Math.max(start, Math.min(pagination.total, 1))}</span> to&nbsp;
<span className={'font-semibold text-neutral-400'}>{end}</span> of&nbsp;
<span className={'font-semibold text-neutral-400'}>{pagination.total}</span> results.
</p>
</td>
</tr>
</tfoot>
);
};
export default TFootPaginated;

View file

@ -91,7 +91,7 @@ export default ({ children, ...props }: Props) => {
{...getFloatingProps({
ref: floating,
className:
'bg-gray-900 text-sm text-gray-200 px-3 py-2 rounded pointer-events-none max-w-[24rem]',
'bg-slate-900 text-sm text-slate-200 px-3 py-2 rounded pointer-events-none max-w-[24rem]',
style: {
position: strategy,
top: `${y || 0}px`,
@ -108,7 +108,7 @@ export default ({ children, ...props }: Props) => {
ay || 0,
)}px) rotate(45deg)`,
}}
className={classNames('absolute bg-gray-900 w-3 h-3', side)}
className={classNames('absolute bg-slate-900 w-3 h-3', side)}
/>
)}
</motion.div>