diff --git a/packages/components/frontend/ConfigEditor.tsx b/packages/components/frontend/ConfigEditor.tsx new file mode 100644 index 000000000..5070c1e9c --- /dev/null +++ b/packages/components/frontend/ConfigEditor.tsx @@ -0,0 +1,162 @@ +import 'schemastery-react/lib/schemastery-react.css'; + +import { Allotment } from 'allotment'; +import { diffLines } from 'diff'; +import yaml from 'js-yaml'; +import React from 'react'; +import { createSchemasteryReact } from 'schemastery-react'; +import { ComponentsContext } from './provider'; + +const locales = { + zh: 'zh-CN', + en: 'en-US', +}; + +function MonacoContainer({ + config, setValue, setError, monaco, schema, editorCallback, size, +}) { + const editorRef = React.useRef(null); + const [loading, setLoading] = React.useState(false); + const [editor, setEditor] = React.useState(null); + const [model, setModel] = React.useState(null); + const { codeFontFamily } = React.useContext(ComponentsContext); + + React.useEffect(() => { + if (!monaco) return; + const interval = setInterval(() => { + monaco.editor.remeasureFonts(); + }, 1000); + return () => clearInterval(interval); // eslint-disable-line consistent-return + }, [monaco]); + + React.useEffect(() => { + const listener = () => { + if (!editor) return; + editor.layout(); + }; + window.addEventListener('resize', listener); + return () => window.removeEventListener('resize', listener); + }, [editor]); + React.useEffect(() => { + if (!editor) return; + editor.layout(); + }, [size]); + 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); + } + }); + 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: codeFontFamily, + fontLigatures: '', + unicodeHighlight: { + ambiguousCharacters: true, + }, + }); + editorCallback(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: { range: any; text: string }[] = []; + 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 ( +
+ ); +} + +export default function ConfigEditor({ + schema, config, monaco, Markdown, onSave, registerAction, sidebar, +}) { + const [value, setValue] = React.useState(yaml.load(config)); + const [info, setInfo] = React.useState(''); + const [stringConfig, setStringConfig] = React.useState(config); + const initial = React.useMemo(() => yaml.load(config), []); + const { i18n } = React.useContext(ComponentsContext); + + const Form = React.useMemo(() => createSchemasteryReact({ + locale: locales[i18n('__id')] || 'en-US', + Markdown, + }), []); + + 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]); + + const [size, setSize] = React.useState([50, 50]); + + return (
+ + + +
{info}
+ +
+ +
+
+
+
+ {sidebar && {sidebar}} +
+
); +} diff --git a/packages/components/frontend/Icon.tsx b/packages/components/frontend/Icon.tsx new file mode 100644 index 000000000..1d30d31f2 --- /dev/null +++ b/packages/components/frontend/Icon.tsx @@ -0,0 +1,20 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +export default function IconComponent(props: { name: string, className?: string } & React.HTMLAttributes) { + const { + name, + className, + ...rest + } = props; + const cn = classNames(className, `icon icon-${name}`); + return ( + + ); +} + +IconComponent.propTypes = { + name: PropTypes.string.isRequired, + className: PropTypes.string, +}; diff --git a/packages/components/frontend/Layout.tsx b/packages/components/frontend/Layout.tsx new file mode 100644 index 000000000..6523cadbc --- /dev/null +++ b/packages/components/frontend/Layout.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export function Row({ children }: { children: React.ReactNode }) { + return
+ {children} +
; +} + +export function Column({ children, md }: { children: React.ReactNode, md?: number }) { + return
+ {children} +
; +} diff --git a/packages/components/frontend/Notification.tsx b/packages/components/frontend/Notification.tsx new file mode 100644 index 000000000..f6840c797 --- /dev/null +++ b/packages/components/frontend/Notification.tsx @@ -0,0 +1,26 @@ +import { toast } from 'react-toastify'; + +export default class Notification { + type: string; + action: any; + $dom: JQuery; + $n: JQuery; + duration: number; + autoHideTimer?: NodeJS.Timeout; + + static async success(message: string, duration?: number) { + return toast.success(message, { autoClose: duration }); + } + + static async info(message: string, duration?: number) { + return toast.info(message, { autoClose: duration }); + } + + static async warn(message: string, duration?: number) { + return toast.warning(message, { autoClose: duration }); + } + + static async error(message: string, duration?: number) { + return toast.error(message, { autoClose: duration }); + } +} diff --git a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx b/packages/components/frontend/autocomplete/AutoComplete.tsx similarity index 99% rename from packages/ui-default/components/autocomplete/components/AutoComplete.tsx rename to packages/components/frontend/autocomplete/AutoComplete.tsx index 885b8334a..f7b6f4224 100644 --- a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx +++ b/packages/components/frontend/autocomplete/AutoComplete.tsx @@ -1,3 +1,5 @@ +import './autocomplete.scss'; + import { debounce, uniqueId } from 'lodash'; import React, { forwardRef, useEffect, @@ -5,7 +7,7 @@ import React, { } from 'react'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; -import Icon from 'vj/components/react/IconComponent'; +import Icon from '../Icon'; export interface AutoCompleteProps { width?: string; diff --git a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx b/packages/components/frontend/autocomplete/CustomSelectAutoComplete.tsx similarity index 64% rename from packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx rename to packages/components/frontend/autocomplete/CustomSelectAutoComplete.tsx index c9322d3af..31b5b6f0b 100644 --- a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx +++ b/packages/components/frontend/autocomplete/CustomSelectAutoComplete.tsx @@ -1,3 +1,4 @@ +import { defaults } from 'lodash'; import PropTypes from 'prop-types'; import React from 'react'; import AutoComplete from './AutoComplete'; @@ -7,18 +8,27 @@ type DefaultProps = { name: string; } & string; -const CustomSelectAutoComplete = React.forwardRef((props, ref) => ( - +const CustomSelectAutoComplete = React.forwardRef((props, ref) => { + const def = defaults(props, { + width: '100%', + height: 'auto', + listStyle: {}, + multi: false, + selectedKeys: [], + freeSolo: false, + freeSoloConverter: (input) => input, + }); + return 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()))} + fetchItems={(keys) => def.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))} + queryItems={(query) => def.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} - /> -)); + {...def} + />; +}); CustomSelectAutoComplete.propTypes = { width: PropTypes.string, @@ -35,14 +45,4 @@ CustomSelectAutoComplete.propTypes = { CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete'; -CustomSelectAutoComplete.defaultProps = { - width: '100%', - height: 'auto', - listStyle: {}, - multi: false, - selectedKeys: [], - freeSolo: false, - freeSoloConverter: (input) => input, -}; - export default CustomSelectAutoComplete; diff --git a/packages/components/frontend/autocomplete/autocomplete.scss b/packages/components/frontend/autocomplete/autocomplete.scss new file mode 100644 index 000000000..67fe238fe --- /dev/null +++ b/packages/components/frontend/autocomplete/autocomplete.scss @@ -0,0 +1,131 @@ +@use '../common' as *; + +.autocomplete-dummy { + display: none !important; +} + +.autocomplete-wrapper { + border: 1px solid #d9d9d9; + padding: 1px; + display: flex; + flex-wrap: wrap; + overflow-y: auto; + + @include 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; + } + } + } +} \ No newline at end of file diff --git a/packages/components/frontend/common.scss b/packages/components/frontend/common.scss new file mode 100644 index 000000000..9ab646fbe --- /dev/null +++ b/packages/components/frontend/common.scss @@ -0,0 +1,202 @@ +@use "sass:color"; +@use "sass:list"; +@use "sass:math"; + +@function toRem($value) { + $remValue: math.div($value, 16) + rem; + @return $remValue; +} +@function rem($values) { + $remValues: (); + @each $value in $values { + $remValues: list.append($remValues, toRem($value)); + } + @return $remValues; +} +@function lighten($color, $amount) { + @return color.adjust($color, $lightness: $amount); +} + +// layout +$grid-padding: 15px; // half gutter + +// fonts +$primary-font-family: var(--font-family); +$header-font-family: var(--font-family); +$code-font-family: var(--code-font-family); + +// primary palette +$default-color: #ddd; +$primary-color: #5f9fd6; +$secondary-color: #ed5f82; +$highlight-color: #df6589; +$code-color: #c92a2a; +$text-1-color: #555; +$text-2-color: lighten($text-1-color, 15%); +$text-3-color: lighten($text-1-color, 30%); +$header-1-color: #3d3d3d; +$header-2-color: lighten($header-1-color, 20%); +$blockquote-color: #999; +$page-bg-color: #edf0f2; +$content-bg-color: #fff; +$border-1-color: $default-color; +$border-2-color: lighten($default-color, 20%); + +$immersive-primary-color: #ffef87; +$immersive-text-color: #ffffff; +$immersive-header-color: #ffffff; + +$success-color: #25ad40; +$fail-color: #fb5555; +$progress-color: #f39800; +$invalid-color: #9fa0a0; + +// sizes +$font-size: 16px; // main content +$font-size-secondary: 14px; // secondary content +$font-size-small: 13px; // additional informations, usually gray colored +$font-size-icon: 16px; +$font-size-title: 20px; + +// components +$section-gap-v: 15px; +$section-gap-h: $grid-padding; +$section-list-v: 25px; +$section-margin: 30px; +$section-shadow: 0 rem(6px) rem(22px) rgba(#AFC2C9, 0.5); +$section-bg-color: $content-bg-color; +$section-title-color: $header-2-color; +$section-toolbtn-color: #AAA; +$section-toolbtn-color-hover: #444; + +$autocomplete-empty-row-color: #AAA; +$userselect-uid-color: #BBB; + +$editor-border-color: $border-1-color; + +$datepicker-shadow: 0 rem(2px) rem(7px) rgba(#000, 0.3); +$datapicker-list-item-bg-color-hover: #F0F0F0; + +$dialog-layer-bg-color: rgba(#F0F0F0, 0.6); +$dialog-bg-color: $content-bg-color; +$dialog-border-color: $border-1-color; +$dialog-shadow: 0 rem(2px) rem(10px) rgba(#000, 0.2); +$dialog-title-color: $header-2-color; + +$comment-placeholder-color: #AAA; +$comment-op-color: lighten($text-1-color, 30%); +$comment-op-link-color: lighten($text-1-color, 50%); + +$menu-item-bg-color-hover: #F4F4F4; +$menu-drop-shadow: 0 rem(2px) rem(7px) rgba(#000, 0.3); +$menu-drop-bg-color: $content-bg-color; +$menu-drop-triangle-size: 4px; +$menu-drop-triangle-shadow: rem(-2px) rem(-2px) rem(2px) rgba(#000, 0.15); + +$nav-item-height: 45px; +$nav-item-hover-color: $primary-color; +$nav-item-active-color: $secondary-color; +$nav-item-active-immersive-color: #9AE3F3; +$nav-item-round-height: 32px; +$nav-item-round-color: $primary-color; +$nav-item-round-border: 2px; +// $nav-logo-height: 32px; +// $nav-logo-height-mobile: 23px; + +$header-layer-height: 100px; +$header-bg-width: 1920px; +$header-bg-height: 320px; +$header-bg-height-mini: $header-bg-height - $header-layer-height; +//$header-logo-width = 280px +//$header-logo-height = 150px +//$header-logo-width-real = 280px +//$header-logo-height-real = 150px +$header-logo-width: 252px; +$header-logo-height: 150px; +$header-logo-width-real: 381px; +$header-logo-height-real: 201px; +$header-logo-domain-lh: 60px; +$header-logo-system-lh: 30px; + +$footer-bg-color: $content-bg-color; + +$input-focus-color: #C1E0FF; +$form-control-height: 35px; +$compact-control-height: 28px; +$input-margin: 0 0 1rem; +$input-multi-line-margin: 0 0.3rem 0.15rem 0; +$input-image-radio-margin: 0 0.3rem 0.3rem 0; +$input-background-color: $content-bg-color; +$input-border: 1px solid $border-1-color; +$input-outline: 2px solid transparent; +$input-focus-border-color: $primary-color; +$input-focus-outline: 2px solid $input-focus-color; +$label-color: lighten($text-1-color, 10%); +$help-text-color: lighten($text-1-color, 30%); + +$table-border-color: $border-2-color; +$table-alternate-bg-color: #F4F4F4; +$table-header-bg-color: rgba(#FFF, 0.95); +$table-header-shadow: rem(0 3px 2px) rgba(#000, 0.03); +$table-header-shadow-floating: rem(0 2px 5px) rgba(#000, 0.2); +$table-padding-h: 10px; +$table-lh: 26px; + +$toolbar-bg: #f2f6f7; +$toolbar-fore: #8395a1; +$toolbar-fore-hover: #5a6b75; +$toolbar-bg-hover: #e3e7e8; +$toolbar-fore-selected: #FFF; +$toolbar-bg-selected: $secondary-color; +$toolbar-disabled-opacity: 0.3; + +$supplementary-color: lighten($text-1-color, 50%); +$supplementary-border-color: lighten($supplementary-color, 62%); +$supplementary-link-color: lighten($text-1-color, 40%); + +$pager-border-color: $table-border-color; + +$nprogress-color: $primary-color; + +$syntax-hl-bg-color: #F8F8F8; + +// // Notification +// $notification-color = { +// 'success': #9BDB7B, +// 'info': #78D6F4, +// 'warn': #FCD279, +// 'error': #FD848D, +// } + +// $notification-icon = { +// 'success': $icon-check--circle, +// 'info': $icon-info--circle, +// 'warn': $icon-info--circle, +// 'error': $icon-close--circle, +// } + + +@mixin form-styles() { + appearance: none; + display: block; + width: 100%; + font-size: rem($font-size-secondary); + margin: $input-margin; + height: rem($form-control-height); + line-height: 1.2; + padding: rem(5px); + border: $input-border; + + &.inline { + display: inline-block; + width: auto; + } +} + +.fullscreen-content { + position: absolute; + left: 0; + top: $nav-item-height; + width: 100%; + bottom: 0; +} diff --git a/packages/components/frontend/index.ts b/packages/components/frontend/index.ts new file mode 100644 index 000000000..f8410925b --- /dev/null +++ b/packages/components/frontend/index.ts @@ -0,0 +1,6 @@ +export { default as ComponentsProvider } from './provider'; +export { default as ConfigEditor } from './ConfigEditor'; +export { default as Icon } from './Icon'; +export { default as Notification } from './Notification'; +export { default as AutoComplete, type AutoCompleteHandle, type AutoCompleteProps } from './autocomplete/AutoComplete'; +export { default as CustomSelectAutoComplete } from './autocomplete/CustomSelectAutoComplete'; diff --git a/packages/components/frontend/provider.tsx b/packages/components/frontend/provider.tsx new file mode 100644 index 000000000..13324c3f2 --- /dev/null +++ b/packages/components/frontend/provider.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { ToastContainer } from 'react-toastify'; + +interface C { + i18n: (key: string, ...args: any[]) => string; + theme: 'light' | 'dark'; + codeFontFamily: string; +} + +// eslint-disable-next-line +export const ComponentsContext = React.createContext({ + i18n: (key) => key, + theme: 'light', + codeFontFamily: 'monospace', +}); + +export default function ComponentsProvider(props: { children: React.ReactNode } & C) { + return + + {props.children} + ; +} diff --git a/packages/components/package.json b/packages/components/package.json new file mode 100644 index 000000000..63a40eef8 --- /dev/null +++ b/packages/components/package.json @@ -0,0 +1,20 @@ +{ + "name": "@hydrooj/components", + "version": "1.0.0-alpha.0", + "main": "./frontend/index.ts", + "dependencies": { + "diff": "^7.0.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-toastify": "^11.0.5", + "schemastery-react": "^0.1.2" + }, + "devDependencies": { + "@types/diff": "^7.0.2", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.17.16", + "sass": "^1.86.0" + } +} diff --git a/packages/ui-default/build/config/webpack.ts b/packages/ui-default/build/config/webpack.ts index 45bd6efa2..8291899fe 100644 --- a/packages/ui-default/build/config/webpack.ts +++ b/packages/ui-default/build/config/webpack.ts @@ -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()], diff --git a/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx index 760850fa9..1c7b53989 100644 --- a/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx @@ -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[] diff --git a/packages/ui-default/components/autocomplete/autocomplete.page.styl b/packages/ui-default/components/autocomplete/autocomplete.page.styl deleted file mode 100644 index 9eb2ddab7..000000000 --- a/packages/ui-default/components/autocomplete/autocomplete.page.styl +++ /dev/null @@ -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 diff --git a/packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.tsx index 48fac31ba..aa0f9bfaf 100644 --- a/packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.tsx @@ -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, AutoCompleteProps>((props, ref) => ( diff --git a/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx index 2c13d9055..9ea0a9f5d 100644 --- a/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx @@ -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; diff --git a/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx index 64468884a..d62bf872c 100644 --- a/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx @@ -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, AutoCompleteProps>((props, ref) => ( diff --git a/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx index 8d437f72a..ae6d6d36e 100644 --- a/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx @@ -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, AutoCompleteProps>((props, ref) => ( diff --git a/packages/ui-default/components/autocomplete/index.tsx b/packages/ui-default/components/autocomplete/index.tsx index 52f0a6a01..c538acc4a 100644 --- a/packages/ui-default/components/autocomplete/index.tsx +++ b/packages/ui-default/components/autocomplete/index.tsx @@ -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?: Multi; diff --git a/packages/ui-default/components/problemconfig/BasicForm.tsx b/packages/ui-default/components/problemconfig/BasicForm.tsx index 381cfc5e4..79f24ad81 100644 --- a/packages/ui-default/components/problemconfig/BasicForm.tsx +++ b/packages/ui-default/components/problemconfig/BasicForm.tsx @@ -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'; diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx index ef37a4bfa..1d6062de2 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -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'; diff --git a/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx b/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx index bcfce9104..32356cc3e 100644 --- a/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx +++ b/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx @@ -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 { diff --git a/packages/ui-default/index.ts b/packages/ui-default/index.ts index 36859c0bd..bdf96664f 100644 --- a/packages/ui-default/index.ts +++ b/packages/ui-default/index.ts @@ -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); diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index fb301596f..e0a30e34b 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -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", diff --git a/packages/ui-default/pages/setting.page.tsx b/packages/ui-default/pages/setting.page.tsx index eae694ad0..30c70d0dd 100644 --- a/packages/ui-default/pages/setting.page.tsx +++ b/packages/ui-default/pages/setting.page.tsx @@ -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
; - }, - }); + 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
; + } - 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(null); - const [loading, setLoading] = React.useState(false); - const [editor, setEditor] = React.useState(null); - const [model, setModel] = React.useState(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( + + { + request.post('', { value }).then(() => { + Notification.success(i18n('Changes saved successfully')); + }).catch((e) => { + Notification.error(i18n('Failed to save changes:'), e.message); + }); + }} + sidebar={} + /> + + , + ); }); export default page; diff --git a/packages/ui-default/setting.yaml b/packages/ui-default/setting.yaml index a8f58966a..99017bde9 100644 --- a/packages/ui-default/setting.yaml +++ b/packages/ui-default/setting.yaml @@ -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 diff --git a/packages/ui-default/templates/manage_config.html b/packages/ui-default/templates/manage_config.html index 16955a37c..0eb3f18a3 100644 --- a/packages/ui-default/templates/manage_config.html +++ b/packages/ui-default/templates/manage_config.html @@ -1,10 +1,6 @@ {% extends "manage_base.html" %} {% block manage_content %} -
-
{{ set(UiContext, 'schema', schema) }} {{ set(UiContext, 'config', value) }} -
-
-
+
{% endblock %}