ui(admin): add "working" React admin ui
This commit is contained in:
parent
d1c7494933
commit
5402584508
199 changed files with 13387 additions and 151 deletions
|
@ -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}
|
||||
|
|
|
@ -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.`
|
||||
|
|
318
resources/scripts/components/elements/Editor.tsx
Normal file
318
resources/scripts/components/elements/Editor.tsx
Normal 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}/>
|
||||
);
|
||||
};
|
|
@ -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`};
|
||||
}
|
||||
`;
|
||||
|
|
315
resources/scripts/components/elements/SearchableSelect.tsx
Normal file
315
resources/scripts/components/elements/SearchableSelect.tsx
Normal 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;
|
347
resources/scripts/components/elements/SelectField.tsx
Normal file
347
resources/scripts/components/elements/SelectField.tsx
Normal 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 };
|
|
@ -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'}> — </span>
|
||||
<span className={'text-slate-400'}> — </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'}> | </span>
|
||||
<span className={'text-slate-400'}> | </span>
|
||||
</span>
|
||||
)}
|
||||
<Tooltip placement={'right'} content={format(activity.timestamp, 'MMM do, yyyy H:mm:ss')}>
|
||||
|
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
<span className={'font-semibold text-neutral-400'}>{end}</span> of
|
||||
<span className={'font-semibold text-neutral-400'}>{pagination.total}</span> results.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default TFootPaginated;
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue