mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
ui: better config page layout
This commit is contained in:
27 files changed
+748
-377
No files matched your search
@@ -88,6 +88,14 @@ export default async function (env: { watch?: boolean, production?: boolean, mea
|
||||
};
|
||||
}
|
||||
|
||||
function scssLoader() {
|
||||
return {
|
||||
loader: 'sass-loader',
|
||||
options: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const config: import('webpack').Configuration = {
|
||||
// bail: !env.production,
|
||||
mode: (env.production || env.measure) ? 'production' : 'development',
|
||||
@@ -200,6 +208,10 @@ export default async function (env: { watch?: boolean, production?: boolean, mea
|
||||
test: /\.styl$/,
|
||||
use: [extractCssLoader(), cssLoader(), postcssLoader(), stylusLoader()],
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: [extractCssLoader(), cssLoader(), postcssLoader(), scssLoader()],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [extractCssLoader(), cssLoader(), postcssLoader()],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CustomSelectAutoComplete as CustomSelectAutoCompleteFC } from '@hydrooj/components';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import AutoComplete, { AutoCompleteOptions } from '.';
|
||||
import CustomSelectAutoCompleteFC from './components/CustomSelectAutoComplete';
|
||||
|
||||
interface CustomSelectOptions {
|
||||
data: any[]
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
.autocomplete-dummy
|
||||
display: none !important
|
||||
|
||||
.autocomplete-wrapper
|
||||
border: 1px solid #d9d9d9
|
||||
padding: 1px
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
overflow-y: auto
|
||||
|
||||
form-styles()
|
||||
background-color: $input-background-color
|
||||
outline: $input-outline
|
||||
transition: outline-color .2s, border-color .2s
|
||||
transition-timing-function: ease-out-cubic
|
||||
|
||||
&:focus, &.focused
|
||||
border-color: $input-focus-border-color
|
||||
outline: $input-focus-outline
|
||||
outline-offset: 0
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2)
|
||||
|
||||
// Overwrite, for IE compatibility
|
||||
&:read-only
|
||||
outline: 0
|
||||
|
||||
&:disabled
|
||||
opacity: 0.5
|
||||
|
||||
&:hover
|
||||
border-color: #40a9ff
|
||||
|
||||
input
|
||||
background-color: $input-background-color
|
||||
color: rgba(0,0,0,.85)
|
||||
height: 30px
|
||||
box-sizing: border-box
|
||||
padding: 4px 6px
|
||||
width: 0
|
||||
min-width: 30px
|
||||
flex-grow: 1
|
||||
border: 0
|
||||
margin: 0
|
||||
outline: 0
|
||||
|
||||
.autocomplete-tag
|
||||
display: flex
|
||||
align-items: center
|
||||
height: 24px
|
||||
margin: 2px
|
||||
line-height: 22px
|
||||
background-color: #fafafa
|
||||
border: 1px solid #e8e8e8
|
||||
border-radius: 2px
|
||||
box-sizing: content-box
|
||||
padding: 0 4px 0 10px
|
||||
outline: 0
|
||||
overflow: hidden
|
||||
|
||||
&:focus
|
||||
border-color: #40a9ff
|
||||
background-color: #e6f7ff
|
||||
|
||||
&>div
|
||||
overflow: hidden
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
|
||||
&>span.bp5-icon>svg
|
||||
font-size: 12px
|
||||
cursor: pointer
|
||||
padding: 4px
|
||||
|
||||
.autocomplete-list
|
||||
min-width: 300px
|
||||
margin: 2px 0 0
|
||||
padding: 0
|
||||
position: absolute
|
||||
list-style: none
|
||||
background-color: #fff
|
||||
overflow: auto
|
||||
max-height: 500px
|
||||
border-radius: 4px
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15)
|
||||
z-index: 999
|
||||
|
||||
li
|
||||
padding: 5px 12px
|
||||
display: flex
|
||||
|
||||
&>div
|
||||
flex-grow: 1
|
||||
|
||||
&>span.bp5-icon>svg
|
||||
color: transparent
|
||||
|
||||
&[aria-selected='true']
|
||||
background-color: #fafafa
|
||||
font-weight: 600
|
||||
|
||||
&>span.bp5-icon>svg
|
||||
color: #1890ff
|
||||
|
||||
&[data-focus='true']
|
||||
background-color: #e6f7ff
|
||||
cursor: pointer
|
||||
|
||||
&>span.bp5-icon>svg
|
||||
color: currentColor
|
||||
@@ -1,349 +0,0 @@
|
||||
import { debounce, uniqueId } from 'lodash';
|
||||
import React, {
|
||||
forwardRef, useEffect,
|
||||
useImperativeHandle, useRef, useState,
|
||||
} from 'react';
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import Icon from 'vj/components/react/IconComponent';
|
||||
|
||||
export interface AutoCompleteProps<Item> {
|
||||
width?: string;
|
||||
/**
|
||||
* if you need fix height, set to at least "30px"
|
||||
* for Hydro, no less then "34px" can be better
|
||||
*/
|
||||
height?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
disabledHint?: string;
|
||||
listStyle?: React.CSSProperties;
|
||||
cacheKey?: string;
|
||||
renderItem?: (item: Item) => any;
|
||||
queryItems?: (query: string) => Promise<Item[]> | Item[];
|
||||
fetchItems?: (ids: string[]) => Promise<Item[]> | Item[];
|
||||
itemText?: (item: Item) => string;
|
||||
itemKey?: (item: Item) => string;
|
||||
onChange?: (value: string) => any;
|
||||
multi?: boolean;
|
||||
draggable?: boolean;
|
||||
selectedKeys?: string[];
|
||||
allowEmptyQuery?: boolean;
|
||||
freeSolo?: boolean;
|
||||
freeSoloConverter?: (value: string) => string;
|
||||
}
|
||||
|
||||
export interface AutoCompleteHandle<Item> {
|
||||
getSelectedItems: () => Item[];
|
||||
getSelectedItemKeys: () => string[];
|
||||
getSelectedItemsAsString: () => string;
|
||||
setSelectedItems: (items: Item[]) => void;
|
||||
getQuery: () => string;
|
||||
setQuery: (query: string) => void;
|
||||
setSelectedKeys: (keys: string[]) => void;
|
||||
triggerQuery: () => any;
|
||||
closeList: () => void;
|
||||
/** returns comma seperated value text */
|
||||
getValue: () => string;
|
||||
/** return value detail */
|
||||
getValueArray: () => Item[];
|
||||
clear: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const superCache = {};
|
||||
|
||||
function DraggableSelection({
|
||||
type, id, move, children, ...props
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, drag] = useDrag<{ id: string }, any, boolean>(() => ({
|
||||
type,
|
||||
item: { id },
|
||||
collect: (m) => m.isDragging(),
|
||||
}));
|
||||
const [, drop] = useDrop({
|
||||
accept: type,
|
||||
hover: (item: { id: string }) => {
|
||||
if (!ref.current) return;
|
||||
move(item.id, id);
|
||||
},
|
||||
});
|
||||
drag(drop(ref));
|
||||
return (
|
||||
<div ref={ref} {...props} style={{ opacity: isDragging ? 0.2 : 1 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-arrow-callback
|
||||
const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, ref: React.Ref<AutoCompleteHandle<T>>) {
|
||||
const {
|
||||
multi = false, width = '100%', height = 'auto',
|
||||
freeSolo = false, allowEmptyQuery = false, listStyle = {}, // eslint-disable-line @eslint-react/no-unstable-default-props
|
||||
disabled = false, disabledHint = '', draggable = multi,
|
||||
} = props;
|
||||
const queryItems = props.queryItems ?? (() => []);
|
||||
const renderItem = props.renderItem ?? ((item) => item);
|
||||
const itemText = props.itemText ?? ((item) => item.toString());
|
||||
const itemKey = props.itemKey ?? itemText;
|
||||
const onChange = props.onChange ?? (() => { });
|
||||
const freeSoloConverter = freeSolo ? props.freeSoloConverter ?? ((i) => i) : ((i) => i);
|
||||
|
||||
const [focused, setFocused] = useState(false); // is focused
|
||||
const [selected, setSelected] = useState([]); // selected items
|
||||
const [selectedKeys, setSelectedKeys] = useState(props.selectedKeys || []); // keys of selected items
|
||||
const [itemList, setItemList] = useState([]); // items list
|
||||
const [currentItem, setCurrentItem] = useState(null); // index of current item (in item list)
|
||||
const [rerender, setRerender] = useState(false);
|
||||
const [draggableId] = useState(uniqueId());
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
let [queryCache, valueCache] = [useRef({}).current, useRef({}).current];
|
||||
if (props.cacheKey) {
|
||||
superCache[props.cacheKey] ||= { query: {}, value: {} };
|
||||
queryCache = superCache[props.cacheKey].query;
|
||||
valueCache = superCache[props.cacheKey].value;
|
||||
}
|
||||
|
||||
const queryList = async (query) => {
|
||||
if (!query && !allowEmptyQuery) {
|
||||
setItemList([]);
|
||||
setCurrentItem(null);
|
||||
return;
|
||||
}
|
||||
queryCache[query] ||= await queryItems(query);
|
||||
for (const item of queryCache[query]) valueCache[itemKey(item)] = item;
|
||||
setItemList(queryCache[query]);
|
||||
setCurrentItem((!freeSolo && queryCache[query].length) ? 0 : null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedKeys(props.selectedKeys || []);
|
||||
}, [JSON.stringify(props.selectedKeys)]);
|
||||
const dispatchChange = () => {
|
||||
if (!multi) onChange(inputRef.current?.value);
|
||||
else onChange(selectedKeys.filter((v) => v?.trim().length).join(','));
|
||||
};
|
||||
|
||||
let first = !multi;
|
||||
useEffect(() => {
|
||||
if (first) first = false;
|
||||
else dispatchChange();
|
||||
if (!multi) return; // Load pre-selected items only in multi mode
|
||||
const ids = [];
|
||||
for (const key of selectedKeys) if (!valueCache[key]) ids.push(key);
|
||||
if (!ids.length) return;
|
||||
Promise.resolve(props.fetchItems(ids)).then((items) => {
|
||||
for (const item of items) valueCache[itemKey(item)] = item;
|
||||
setRerender(!rerender);
|
||||
});
|
||||
}, [selectedKeys, multi]);
|
||||
|
||||
const handleInputChange = debounce((e?) => queryList(e ? e.target.value : ''), 300);
|
||||
|
||||
const toggleItem = (item: T, key = itemKey(item), preserve = false) => {
|
||||
const shouldKeepOpen = multi && allowEmptyQuery && inputRef.current.value === '';
|
||||
if (multi) {
|
||||
const idx = selectedKeys.indexOf(key);
|
||||
if (idx !== -1) {
|
||||
setSelected((s) => {
|
||||
const newSelected = [...s];
|
||||
newSelected.splice(idx, 1);
|
||||
return newSelected;
|
||||
});
|
||||
setSelectedKeys((s) => {
|
||||
const newSelectedKeys = [...s];
|
||||
newSelectedKeys.splice(idx, 1);
|
||||
return newSelectedKeys;
|
||||
});
|
||||
} else {
|
||||
setSelected((s) => [...s, item]);
|
||||
setSelectedKeys((s) => [...s, key]);
|
||||
}
|
||||
if (!preserve) inputRef.current.value = '';
|
||||
inputRef.current.focus();
|
||||
} else {
|
||||
setSelected([item]);
|
||||
setSelectedKeys([key]);
|
||||
inputRef.current.value = key;
|
||||
}
|
||||
dispatchChange();
|
||||
if (!shouldKeepOpen) {
|
||||
setItemList([]);
|
||||
setCurrentItem(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e) => {
|
||||
const { key, target } = e;
|
||||
if (key === 'Escape') {
|
||||
setItemList([]);
|
||||
setCurrentItem(null);
|
||||
return;
|
||||
}
|
||||
if (key === 'Enter' || key === ',') {
|
||||
e.preventDefault();
|
||||
if (currentItem !== null) {
|
||||
toggleItem(itemList[currentItem]);
|
||||
return;
|
||||
}
|
||||
if (freeSolo && target.value !== '') {
|
||||
toggleItem(freeSoloConverter(target.value));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'Backspace') {
|
||||
if (target.value.length) return;
|
||||
if (selectedKeys.length) {
|
||||
setSelected((s) => s.slice(0, -1));
|
||||
setSelectedKeys((s) => s.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (itemList.length === 0) return;
|
||||
const idx = (currentItem ?? 0) - 1;
|
||||
const newIdx = idx < 0 ? itemList.length - 1 : idx;
|
||||
setCurrentItem(newIdx);
|
||||
listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
if (key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (itemList.length === 0) return;
|
||||
const idx = (currentItem ?? itemList.length - 1) + 1;
|
||||
const newIdx = idx >= itemList.length ? 0 : idx;
|
||||
setCurrentItem(newIdx);
|
||||
listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
// eslint-disable-next-line no-useless-return
|
||||
return;
|
||||
}
|
||||
// TODO: handle other keys
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getSelectedItems: () => selected,
|
||||
getSelectedItemKeys: () => [...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length),
|
||||
getSelectedItemsAsString: () => [...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length).join(','),
|
||||
setSelectedItems: (items) => {
|
||||
setSelected(items);
|
||||
setSelectedKeys(items.map((i) => itemKey(i)));
|
||||
if (!multi && inputRef.current) inputRef.current.value = items.map((i) => itemKey(i)).join(',');
|
||||
},
|
||||
getQuery: () => inputRef.current?.value,
|
||||
setQuery: (query) => {
|
||||
if (inputRef.current) inputRef.current.value = query;
|
||||
},
|
||||
setSelectedKeys: (keys) => setSelectedKeys(keys),
|
||||
triggerQuery: () => queryList(inputRef.current?.value),
|
||||
closeList: () => {
|
||||
setItemList([]);
|
||||
setCurrentItem(null);
|
||||
},
|
||||
getValue: () => (multi ? selectedKeys.join(',') : (inputRef.current.value ?? '')),
|
||||
getValueArray: () => (multi ? selected : [inputRef.current?.value].filter((i) => !!i)),
|
||||
clear: () => {
|
||||
setSelected([]);
|
||||
setSelectedKeys([]);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
},
|
||||
focus: () => {
|
||||
setFocused(true);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}), [selected, selectedKeys, inputRef, multi]);
|
||||
|
||||
const move = (dragId: string, hoverId: string) => {
|
||||
if (dragId === hoverId || !draggable) return;
|
||||
const dragIndex = selectedKeys.indexOf(dragId);
|
||||
const hoverIndex = selectedKeys.indexOf(hoverId);
|
||||
setSelectedKeys((s) => {
|
||||
const a = [...s];
|
||||
a.splice(dragIndex, 1);
|
||||
a.splice(hoverIndex, 0, s[dragIndex]);
|
||||
return a;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="autocomplete-container" style={{ display: 'inline-block', width: '100%', marginBottom: '1rem' }}>
|
||||
<div
|
||||
className={focused ? 'autocomplete-wrapper focused' : 'autocomplete-wrapper'}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
{multi && selectedKeys.map((key) => {
|
||||
const item = valueCache[key];
|
||||
return (
|
||||
<DraggableSelection type={draggableId} id={key} move={move} className="autocomplete-tag" key={item ? key : `draft-${key}`}>
|
||||
<div>{item ? itemText(item) : key}</div>
|
||||
<Icon name="close" onClick={() => toggleItem(item, key, true)} />
|
||||
</DraggableSelection>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
hidden={disabled}
|
||||
onChange={(e) => {
|
||||
dispatchChange();
|
||||
handleInputChange(e);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (allowEmptyQuery) handleInputChange();
|
||||
setFocused(true);
|
||||
}}
|
||||
onPaste={async (e) => {
|
||||
if (!multi) return;
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (!text || (!text.includes(',') && !text.includes(','))) return;
|
||||
e.preventDefault();
|
||||
const ids = text.replace(/,/g, ',').split(',').filter((v) => v?.trim().length && !selectedKeys.includes(v));
|
||||
if (!ids.length) return;
|
||||
const fetched = await props.fetchItems(ids);
|
||||
for (const item of fetched) valueCache[itemKey(item)] = item;
|
||||
setSelected([...selected, ...fetched]);
|
||||
setSelectedKeys([...selectedKeys, ...fetched.map((val) => itemKey(val))]);
|
||||
}}
|
||||
placeholder={props.placeholder}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
defaultValue={multi ? '' : selectedKeys.join(',')}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
{disabled && (
|
||||
<input
|
||||
disabled
|
||||
autoComplete="off"
|
||||
value={disabledHint}
|
||||
/>
|
||||
)}
|
||||
{focused && itemList.length > 0 && (
|
||||
<ul ref={listRef} className="autocomplete-list" style={listStyle} onMouseDown={(e) => e.preventDefault()}>
|
||||
{itemList.map((item, idx) => (
|
||||
<li
|
||||
key={itemKey(item)}
|
||||
onClick={() => toggleItem(item)}
|
||||
onMouseMove={() => setCurrentItem(idx)}
|
||||
data-selected={selectedKeys.includes(itemKey(item))}
|
||||
data-focus={idx === currentItem}
|
||||
>
|
||||
<div>{renderItem(item)}</div>
|
||||
{selectedKeys.includes(itemKey(item)) && <Icon name="check" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}) as (<T>(props: AutoCompleteProps<T> & { ref: React.Ref<AutoCompleteHandle<T>> }) => React.ReactElement) & React.FC;
|
||||
|
||||
AutoComplete.displayName = 'AutoComplete';
|
||||
|
||||
export default AutoComplete;
|
||||
@@ -1,48 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import AutoComplete from './AutoComplete';
|
||||
|
||||
type DefaultProps = {
|
||||
_id: string;
|
||||
name: string;
|
||||
} & string;
|
||||
|
||||
const CustomSelectAutoComplete = React.forwardRef<any, any>((props, ref) => (
|
||||
<AutoComplete<DefaultProps>
|
||||
ref={ref as any}
|
||||
fetchItems={(keys) => props.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))}
|
||||
queryItems={(query) => props.data.filter((i) => (i.name || i).toString().toLowerCase().includes(query.toLowerCase()))}
|
||||
itemText={(item) => `${item.name || item}`}
|
||||
itemKey={(item) => `${item._id?.toString() || item.name || item}`}
|
||||
renderItem={(item) => `${item.name || item}`}
|
||||
allowEmptyQuery
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CustomSelectAutoComplete.propTypes = {
|
||||
width: PropTypes.string,
|
||||
height: PropTypes.string,
|
||||
listStyle: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
multi: PropTypes.bool,
|
||||
selectedKeys: PropTypes.arrayOf(PropTypes.string),
|
||||
allowEmptyQuery: PropTypes.bool,
|
||||
freeSolo: PropTypes.bool,
|
||||
freeSoloConverter: PropTypes.func,
|
||||
data: PropTypes.arrayOf(PropTypes.any),
|
||||
};
|
||||
|
||||
CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete';
|
||||
|
||||
CustomSelectAutoComplete.defaultProps = {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
listStyle: {},
|
||||
multi: false,
|
||||
selectedKeys: [],
|
||||
freeSolo: false,
|
||||
freeSoloConverter: (input) => input,
|
||||
};
|
||||
|
||||
export default CustomSelectAutoComplete;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AutoComplete, AutoCompleteHandle, AutoCompleteProps } from '@hydrooj/components';
|
||||
import type { DomainDoc } from 'hydrooj/src/interface';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { request } from 'vj/utils';
|
||||
import AutoComplete, { AutoCompleteHandle, AutoCompleteProps } from './AutoComplete';
|
||||
|
||||
const DomainSelectAutoComplete = forwardRef<AutoCompleteHandle<DomainDoc>, AutoCompleteProps<DomainDoc>>((props, ref) => (
|
||||
<AutoComplete<DomainDoc>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AutoComplete } from '@hydrooj/components';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import AutoComplete from './AutoComplete';
|
||||
|
||||
type DefaultProps = {
|
||||
_id: string;
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { AutoComplete, AutoCompleteHandle, AutoCompleteProps } from '@hydrooj/components';
|
||||
import type { ProblemDoc } from 'hydrooj/src/interface';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { api, gql, request } from 'vj/utils';
|
||||
import AutoComplete, { AutoCompleteHandle, AutoCompleteProps } from './AutoComplete';
|
||||
|
||||
const ProblemSelectAutoComplete = forwardRef<AutoCompleteHandle<ProblemDoc>, AutoCompleteProps<ProblemDoc>>((props, ref) => (
|
||||
<AutoComplete<ProblemDoc>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AutoComplete, AutoCompleteHandle, AutoCompleteProps } from '@hydrooj/components';
|
||||
import type { Udoc } from 'hydrooj/src/interface';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { api, gql } from 'vj/utils';
|
||||
import AutoComplete, { AutoCompleteHandle, AutoCompleteProps } from './AutoComplete';
|
||||
|
||||
const UserSelectAutoComplete = forwardRef<AutoCompleteHandle<Udoc>, AutoCompleteProps<Udoc>>((props, ref) => (
|
||||
<AutoComplete<Udoc>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { AutoComplete as AutoCompleteFC } from '@hydrooj/components';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||
import AutoCompleteFC from './components/AutoComplete';
|
||||
|
||||
export interface AutoCompleteOptions<Multi extends boolean = boolean> {
|
||||
multi?: Multi;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CustomSelectAutoComplete } from '@hydrooj/components';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { i18n } from 'vj/utils';
|
||||
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
|
||||
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
|
||||
import type { RootState } from './reducer/index';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomSelectAutoComplete } from '@hydrooj/components';
|
||||
import {
|
||||
Card, InputGroup, Tag,
|
||||
} from '@blueprintjs/core';
|
||||
@@ -5,7 +6,6 @@ import { isEqual } from 'lodash';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { i18n } from 'vj/utils';
|
||||
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
|
||||
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
|
||||
import { FormItem } from './BasicForm';
|
||||
import ProblemType from './ProblemType';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomSelectAutoComplete } from '@hydrooj/components';
|
||||
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common';
|
||||
import {
|
||||
Button, ControlGroup,
|
||||
@@ -8,7 +9,6 @@ import { isEqual } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { i18n } from 'vj/utils';
|
||||
import CustomSelectAutoComplete from '../../autocomplete/components/CustomSelectAutoComplete';
|
||||
import { RootState } from '../reducer';
|
||||
|
||||
interface SubtaskSettingsProps {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
ContestModel, Context, Handler, ObjectId, param, PERM, PRIV, ProblemModel, Schema,
|
||||
SettingModel, SystemModel, Types, UserModel,
|
||||
SettingModel, SystemModel, Types, UserModel, yaml,
|
||||
} from 'hydrooj';
|
||||
import convert from 'schemastery-jsonschema';
|
||||
import markdown from './backendlib/markdown';
|
||||
@@ -129,13 +131,58 @@ class RichMediaHandler extends Handler {
|
||||
}
|
||||
}
|
||||
|
||||
const fontRange = {
|
||||
'Open Sans': 'Open Sans',
|
||||
'Seravek': 'Seravek',
|
||||
'Segoe UI': 'Segoe UI',
|
||||
'Verdana': 'Verdana',
|
||||
'PingFang SC': 'PingFang SC',
|
||||
'Hiragino Sans GB': 'Hiragino Sans GB',
|
||||
'Microsoft Yahei': 'Microsoft Yahei',
|
||||
'WenQuanYi Micro Hei': 'WenQuanYi Micro Hei',
|
||||
'sans': 'sans',
|
||||
'XiaoLai SC': '小赖 SC',
|
||||
};
|
||||
const codeFontRange = {
|
||||
'monaco': 'Monaco',
|
||||
'Source Code Pro': 'Source Code Pro',
|
||||
'Consolas': 'Consolas',
|
||||
'Lucida Console': 'Lucida Console',
|
||||
'Fira Code': 'Fira Code',
|
||||
'Roboto Mono': 'Roboto Mono',
|
||||
'Inconsolata': 'Inconsolata',
|
||||
'Hack': 'Hack',
|
||||
'Jetbrains Mono': 'Jetbrains Mono',
|
||||
'DM Mono': 'DM Mono',
|
||||
'Ubuntu Mono': 'Ubuntu Mono',
|
||||
'PT Mono': 'PT Mono',
|
||||
'SF Mono': 'SF Mono',
|
||||
};
|
||||
|
||||
const defaultAbout = (yaml.load(readFileSync(join(__dirname, 'setting.yaml'), 'utf-8')) as any).about.value;
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.inject(['setting'], (c) => {
|
||||
c.setting.PreferenceSetting(
|
||||
SettingModel.Setting('setting_display', 'rounded', false, 'boolean', 'Rounded Corners'),
|
||||
SettingModel.Setting('setting_display', 'skipAnimate', false, 'boolean', 'Skip Animation'),
|
||||
SettingModel.Setting('setting_display', 'showTimeAgo', true, 'boolean', 'Enable Time Ago'),
|
||||
SettingModel.Setting('setting_display', 'fontFamily', 'Open Sans', fontRange, 'Font Family'),
|
||||
SettingModel.Setting('setting_display', 'codeFontFamily', 'Source Code Pro', codeFontRange, 'Code Font Family'),
|
||||
SettingModel.Setting('setting_display', 'theme', 'light', { light: 'Light', dark: 'Dark' }, 'Theme'),
|
||||
SettingModel.Setting('setting_markdown', 'preferredEditorType', 'sv', { sv: 'Split View', monaco: 'Monaco Editor' }, 'Preferred Editor Type'),
|
||||
SettingModel.Setting('setting_highlight', 'showInvisibleChar', false, 'boolean', 'Show Invisible Characters'),
|
||||
SettingModel.Setting('setting_highlight', 'formatCode', true, 'boolean', 'Auto Format Code'),
|
||||
);
|
||||
c.setting.SystemSetting(Schema.object({
|
||||
'ui-default': Schema.object({
|
||||
footer_extra_html: Schema.string().default(''),
|
||||
nav_logo_dark: Schema.string().default('/components/navigation/nav-logo-small_dark.png'),
|
||||
preload: Schema.string().default(''),
|
||||
domainNavigation: Schema.boolean().default(true).description('Show Domain Navigation'),
|
||||
about: Schema.string().role('markdown').default(defaultAbout),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
if (process.env.HYDRO_CLI) return;
|
||||
ctx.Route('wiki_help', '/wiki/help', WikiHelpHandler);
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
"redux-promise-middleware": "^6.2.0",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"rupture": "^0.7.1",
|
||||
"sass": "^1.86.0",
|
||||
"sass-loader": "^16.0.5",
|
||||
"schemastery": "^3.16.1",
|
||||
"schemastery-react": "^0.1.2",
|
||||
"shorty.js": "^1.0.1",
|
||||
|
||||
@@ -1,174 +1,59 @@
|
||||
import 'schemastery-react/lib/schemastery-react.css';
|
||||
|
||||
import { diffLines } from 'diff';
|
||||
import yaml from 'js-yaml';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import Schema from 'schemastery';
|
||||
import Notification from 'vj/components/notification';
|
||||
import DomComponent from 'vj/components/react/DomComponent';
|
||||
import { NamedPage } from 'vj/misc/Page';
|
||||
import { i18n, request } from 'vj/utils';
|
||||
|
||||
const locales = {
|
||||
zh: 'zh-CN',
|
||||
en: 'en-US',
|
||||
};
|
||||
|
||||
const page = new NamedPage('manage_config', async () => {
|
||||
const [{ createSchemasteryReact }, { load }] = await Promise.all([
|
||||
import('schemastery-react'),
|
||||
const [{ ConfigEditor, ComponentsProvider }, { load }] = await Promise.all([
|
||||
import('@hydrooj/components'),
|
||||
import('vj/components/monaco/loader'),
|
||||
]);
|
||||
const { monaco, registerAction, renderMarkdown } = await load(['yaml']);
|
||||
|
||||
const Form = createSchemasteryReact({
|
||||
locale: locales[i18n('__id')] || 'en-US',
|
||||
Markdown({ source }) {
|
||||
const rendered = React.useMemo(() => {
|
||||
const res = renderMarkdown({ value: source });
|
||||
const value = res.element.innerHTML;
|
||||
res.dispose();
|
||||
return value;
|
||||
}, [source]);
|
||||
// Markdown snippet come from trusted backend code, no need to sanitize here
|
||||
return <div dangerouslySetInnerHTML={{ __html: rendered }} />;
|
||||
},
|
||||
});
|
||||
function Markdown({ source }) {
|
||||
const rendered = React.useMemo(() => {
|
||||
const res = renderMarkdown({ value: source });
|
||||
const value = res.element.innerHTML;
|
||||
res.dispose();
|
||||
return value;
|
||||
}, [source]);
|
||||
// Markdown snippet come from trusted backend code, no need to sanitize here
|
||||
return <div dangerouslySetInnerHTML={{ __html: rendered }} />;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
monaco.editor.remeasureFonts();
|
||||
}, 1000);
|
||||
|
||||
const initialConfigString = UiContext.config;
|
||||
const schema = new Schema(UiContext.schema);
|
||||
const initial = yaml.load(UiContext.config);
|
||||
|
||||
function MonacoContainer({ config, setValue, setError }) {
|
||||
const editorRef = React.useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [editor, setEditor] = React.useState<any>(null);
|
||||
const [model, setModel] = React.useState<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = () => {
|
||||
if (!editor) return;
|
||||
editor.layout();
|
||||
};
|
||||
window.addEventListener('resize', listener);
|
||||
return () => window.removeEventListener('resize', listener);
|
||||
}, [editor]);
|
||||
React.useEffect(() => {
|
||||
if (!editor) return;
|
||||
const disposable = editor.onDidChangeModelContent(() => {
|
||||
const val = editor.getValue({ lineEnding: '\n', preserveBOM: false });
|
||||
try {
|
||||
const loaded = yaml.load(val);
|
||||
schema(loaded);
|
||||
setValue(val);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
ReactDOM.createRoot(document.getElementById('app')!).render(
|
||||
<ComponentsProvider theme={UiContext.theme} codeFontFamily={UiContext.codeFontFamily} i18n={i18n}>
|
||||
<ConfigEditor
|
||||
schema={new Schema(UiContext.schema)}
|
||||
config={UiContext.config}
|
||||
monaco={monaco}
|
||||
registerAction={registerAction}
|
||||
Markdown={Markdown}
|
||||
onSave={(value) => {
|
||||
request.post('', { value }).then(() => {
|
||||
Notification.success(i18n('Changes saved successfully'));
|
||||
}).catch((e) => {
|
||||
Notification.error(i18n('Failed to save changes:'), e.message);
|
||||
});
|
||||
}}
|
||||
sidebar={<DomComponent childDom={$('.section.side').get(0)} />}
|
||||
/>
|
||||
<style>{`
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
});
|
||||
return () => disposable.dispose(); // eslint-disable-line
|
||||
}, [editor, setValue, setError]);
|
||||
React.useEffect(() => {
|
||||
if (!editorRef.current || loading) return;
|
||||
setLoading(true);
|
||||
// eslint-disable-next-line
|
||||
const model = monaco.editor.createModel(config, 'yaml', monaco.Uri.parse('hydro://system/setting.yaml'));
|
||||
setModel(model);
|
||||
const e = monaco.editor.create(editorRef.current, {
|
||||
theme: 'vs-light',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: true,
|
||||
lightbulb: { enabled: monaco.editor.ShowLightbulbIconMode.On },
|
||||
model,
|
||||
minimap: { enabled: false },
|
||||
hideCursorInOverviewRuler: true,
|
||||
overviewRulerLanes: 0,
|
||||
overviewRulerBorder: false,
|
||||
fontFamily: UserContext.codeFontFamily,
|
||||
fontLigatures: '',
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: true,
|
||||
},
|
||||
});
|
||||
registerAction(e, model, editorRef.current);
|
||||
setEditor(e);
|
||||
}, [editorRef.current]);
|
||||
React.useEffect(() => {
|
||||
if (!editor) return;
|
||||
const current = editor.getValue({ lineEnding: '\n', preserveBOM: false });
|
||||
const diff = diffLines(current, config);
|
||||
const ops = [];
|
||||
let cursor = 1;
|
||||
for (const line of diff) {
|
||||
if (line.added) {
|
||||
let range = model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: line.value });
|
||||
} else if (line.removed) {
|
||||
let range = model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
cursor += line.count;
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: '' });
|
||||
} else cursor += line.count;
|
||||
}
|
||||
model.pushEditOperations([], ops, undefined);
|
||||
}, [editor, config]);
|
||||
return (
|
||||
<div ref={editorRef} style={{
|
||||
width: '100%', height: '500px',
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
const initialClone = JSON.parse(JSON.stringify(initial));
|
||||
function App() {
|
||||
const [value, setValue] = React.useState(initialClone);
|
||||
const [info, setInfo] = React.useState('');
|
||||
const [stringConfig, setStringConfig] = React.useState(initialConfigString);
|
||||
|
||||
const updateFromForm = React.useCallback((v) => {
|
||||
const newDump = yaml.dump(v);
|
||||
if (newDump === stringConfig) return;
|
||||
setStringConfig(newDump);
|
||||
setValue(v);
|
||||
}, [stringConfig]);
|
||||
const updateFromMonaco = React.useCallback((v) => {
|
||||
if (v === stringConfig) return;
|
||||
setStringConfig(v);
|
||||
setValue(yaml.load(v));
|
||||
}, [stringConfig]);
|
||||
|
||||
return (<>
|
||||
<div className="row">
|
||||
<div className="medium-5 columns">
|
||||
<MonacoContainer config={stringConfig} setValue={updateFromMonaco} setError={setInfo} />
|
||||
<pre className="help-text">{info}</pre>
|
||||
</div>
|
||||
<div className="medium-7 columns">
|
||||
<Form schema={schema} initial={initial} value={value} onChange={updateFromForm} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: '20px' }}>
|
||||
<div className="medium-10 columns">
|
||||
<button onClick={() => {
|
||||
request.post('', { value: stringConfig }).then(() => {
|
||||
Notification.success(i18n('Changes saved successfully'));
|
||||
}).catch((e) => {
|
||||
Notification.error(i18n('Failed to save changes:'), e.message);
|
||||
});
|
||||
}} className="rounded primary button">{i18n('Save All Changes')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('app')!).render(<App />);
|
||||
.footer {
|
||||
display: hidden;
|
||||
}
|
||||
.omnibar-toggle {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
</ComponentsProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
export default page;
|
||||
@@ -1,79 +1,3 @@
|
||||
fontFamily:
|
||||
type:
|
||||
Open Sans: Open Sans
|
||||
Seravek: Seravek
|
||||
Segoe UI: Segoe UI
|
||||
Verdana: Verdana
|
||||
PingFang SC: PingFang SC
|
||||
Hiragino Sans GB: Hiragino Sans GB
|
||||
Microsoft Yahei: Microsoft Yahei
|
||||
WenQuanYi Micro Hei: WenQuanYi Micro Hei
|
||||
sans: sans
|
||||
XiaoLai SC: 小赖 SC
|
||||
family: setting_display
|
||||
category: preference
|
||||
value: Open Sans
|
||||
codeFontFamily:
|
||||
type:
|
||||
monaco: Monaco
|
||||
Source Code Pro: Source Code Pro
|
||||
Consolas: Consolas
|
||||
Lucida Console: Lucida Console
|
||||
Fira Code: Fira Code
|
||||
Roboto Mono: Roboto Mono
|
||||
Inconsolata: Inconsolata
|
||||
Hack: Hack
|
||||
Jetbrains Mono: Jetbrains Mono
|
||||
DM Mono: DM Mono
|
||||
Ubuntu Mono: Ubuntu Mono
|
||||
PT Mono: PT Mono
|
||||
SF Mono: SF Mono
|
||||
family: setting_display
|
||||
category: preference
|
||||
value: Source Code Pro
|
||||
footer_extra_html:
|
||||
type: textarea
|
||||
name: footer_extra_html
|
||||
desc: Footer Extra HTML
|
||||
default: ''
|
||||
nav_logo_dark:
|
||||
type: text
|
||||
name: nav_logo_dark
|
||||
desc: Nav Logo Dark
|
||||
default: /components/navigation/nav-logo-small_dark.png
|
||||
preferredEditorType:
|
||||
type:
|
||||
sv: Split View
|
||||
monaco: Monaco Editor
|
||||
family: markdown
|
||||
category: preference
|
||||
value: sv
|
||||
theme:
|
||||
type:
|
||||
light: Light
|
||||
dark: Dark
|
||||
category: preference
|
||||
value: light
|
||||
family: setting_display
|
||||
showInvisibleChar:
|
||||
type: boolean
|
||||
family: highlight
|
||||
desc: show invisible characters when formatting code
|
||||
category: preference
|
||||
formatCode:
|
||||
type: boolean
|
||||
family: highlight
|
||||
desc: Auto format code
|
||||
category: preference
|
||||
value: true
|
||||
preload:
|
||||
type: text
|
||||
default: ''
|
||||
domainNavigation:
|
||||
type: boolean
|
||||
family: setting_display
|
||||
desc: Show Domain Navigation
|
||||
value: true
|
||||
about:
|
||||
type: markdown
|
||||
name: about
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
{% extends "manage_base.html" %}
|
||||
{% block manage_content %}
|
||||
</div>
|
||||
<div class="medium-12 columns">
|
||||
{{ set(UiContext, 'schema', schema) }}
|
||||
{{ set(UiContext, 'config', value) }}
|
||||
<div class="section">
|
||||
<div class="section__body" id="app"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user