Merge branch 'master' into milkdown

This commit is contained in:
undefined committed 2022-05-16 14:17:42 +08:00
commit 08c0d34420
137 files changed
+2795 -1521

No files matched your search

+2 -1
View File
@@ -81,7 +81,7 @@ module.exports = {
indent: [
'error',
2,
{ SwitchCase: 0 },
{ SwitchCase: 1 },
],
'max-len': ['error', 150],
quotes: 'warn',
@@ -115,6 +115,7 @@ module.exports = {
'no-underscore-dangle': 'off',
'no-await-in-loop': 'off',
'no-lonely-if': 'off',
'no-nested-ternary': 'off',
'no-param-reassign': 'off',
'no-script-url': 'off',
'no-bitwise': 'off',
+4 -5
View File
@@ -81,6 +81,7 @@ export default function (env = {}) {
mode: (env.production || env.measure) ? 'production' : 'development',
profile: true,
context: root(),
devtool: env.production ? 'source-map' : false,
entry: {
hydro: './entry.js',
polyfill: './polyfill.ts',
@@ -100,8 +101,6 @@ export default function (env = {}) {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
alias: {
vj: root(),
'js-yaml': root('utils/yamlCompact'),
'real-js-yaml': require.resolve('js-yaml'),
},
},
module: {
@@ -205,12 +204,12 @@ export default function (env = {}) {
patterns: [
{ from: root('static') },
{ from: `${dirname(require.resolve('monaco-themes/package.json'))}/themes`, to: 'monaco/themes/' },
{ from: root('.build/sharedworker.js'), to: 'sharedworker.js' },
],
}),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: env.production ? '"production"' : '"debug"',
VERSION: JSON.stringify(require('@hydrooj/ui-default/package.json').version),
},
}),
new webpack.LoaderOptionsPlugin({
@@ -224,10 +223,10 @@ export default function (env = {}) {
filename: '[name].[hash:10].worker.js',
customLanguages: [{
label: 'yaml',
entry: require.resolve('@undefined-moe/monaco-yaml/lib/esm/monaco.contribution'),
entry: require.resolve('monaco-yaml/index.js'),
worker: {
id: 'vs/language/yaml/yamlWorker',
entry: require.resolve('@undefined-moe/monaco-yaml/lib/esm/yaml.worker.js'),
entry: require.resolve('monaco-yaml/yaml.worker.js'),
},
}],
}),
+5 -11
View File
@@ -3,7 +3,6 @@
import cac from 'cac';
import fs from 'fs-extra';
import webpack from 'webpack';
import esbuild from 'esbuild';
import WebpackDevServer from 'webpack-dev-server';
import gulp from 'gulp';
import log from 'fancy-log';
@@ -18,15 +17,6 @@ const argv = cac().parse();
async function runWebpack({
watch, production, measure, dev,
}) {
const result = esbuild.build({
bundle: true,
format: 'iife',
entryPoints: [root('sharedworker.ts')],
outfile: root('.build/sharedworker.js'),
watch: dev || watch,
minify: production,
});
if (!dev && !watch) await result;
const compiler = webpack(webpackConfig({ watch, production, measure }));
if (dev) {
const server = new WebpackDevServer(compiler, {
@@ -36,7 +26,7 @@ async function runWebpack({
stats: 'none',
index: root('public'),
proxy: {
context: (path) => !path.includes('sharedworker.js'),
context: (path) => !path.includes('sockjs-node'),
target: 'http://localhost:2333',
ws: true,
},
@@ -98,6 +88,10 @@ async function main() {
if (fs.existsSync('public/default.theme.css')) {
fs.copyFileSync('public/default.theme.css', `public/default-${pkg.version}.theme.css`);
}
if (argv.options.production) {
const files = fs.readdirSync('public');
files.filter((i) => /(^[in]\..+|worker)\.js\.map$/.test(i)).forEach((i) => fs.removeSync(`public/${i}`));
}
}
process.chdir(dir);
}
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from '.';
@@ -33,15 +33,12 @@ export default class DomainSelectAutoComplete extends AutoComplete {
attach() {
const value = this.$dom.val();
ReactDOM.render(
<Component
ref={(ref) => { this.ref = ref; }}
value={value}
onChange={this.onChange}
multi={this.options.multi}
/>,
this.container,
);
ReactDOM.createRoot(this.container).render(<Component
ref={(ref) => { this.ref = ref; }}
value={value}
onChange={this.onChange}
multi={this.options.multi}
/>);
}
}
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from '.';
@@ -33,14 +33,13 @@ export default class ProblemSelectAutoComplete extends AutoComplete {
attach() {
const value = this.$dom.val();
ReactDOM.render(
ReactDOM.createRoot(this.container).render(
<Component
ref={(ref) => { this.ref = ref; }}
value={value}
multi={this.options.multi}
onChange={this.onChange}
/>,
this.container,
);
}
}
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete, { AutoCompleteOptions } from '.';
@@ -38,14 +38,13 @@ export default class UserSelectAutoComplete<Multi extends boolean> extends AutoC
attach() {
const value = this.$dom.val();
ReactDOM.render(
ReactDOM.createRoot(this.container).render(
<Component
ref={(ref) => { this.ref = ref; }}
value={value}
multi={this.options.multi}
onChange={this.onChange}
/>,
this.container,
);
}
}
@@ -53,7 +53,7 @@
white-space: nowrap
text-overflow: ellipsis
&>span.bp3-icon>svg
&>span.bp4-icon>svg
font-size: 12px
cursor: pointer
padding: 4px
@@ -78,19 +78,19 @@
&>div
flex-grow: 1
&>span.bp3-icon>svg
&>span.bp4-icon>svg
color: transparent
&[aria-selected='true']
background-color: #fafafa
font-weight: 600
&>span.bp3-icon>svg
&>span.bp4-icon>svg
color: #1890ff
&[data-focus='true']
background-color: #e6f7ff
cursor: pointer
&>span.bp3-icon>svg
&>span.bp4-icon>svg
color: currentColor
@@ -17,6 +17,7 @@ export interface AutoCompleteProps<Item> {
*/
height?: string;
disabled?: boolean;
placeholder?: string;
disabledHint?: string;
listStyle?: React.CSSProperties;
cacheKey?: string;
@@ -128,8 +129,10 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
else onChange(selectedKeys.join(','));
};
let first = !multi;
useEffect(() => {
dispatchChange();
if (first) first = false;
else dispatchChange();
const ids = [];
for (const key of selectedKeys) if (!valueCache[key]) ids.push(key);
if (!ids.length) return;
@@ -293,6 +296,7 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
if (allowEmptyQuery) handleInputChange();
setFocused(true);
}}
placeholder={props.placeholder}
onBlur={() => setFocused(false)}
onKeyDown={handleInputKeyDown}
defaultValue={multi ? '' : selectedKeys.join(',')}
@@ -342,6 +346,7 @@ AutoComplete.propTypes = {
allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func,
placeholder: PropTypes.string,
};
AutoComplete.defaultProps = {
@@ -33,7 +33,7 @@ CustomSelectAutoComplete.propTypes = {
allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func,
data: PropTypes.any,
data: PropTypes.arrayOf(PropTypes.any),
};
CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete';
@@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import AutoComplete from './AutoComplete';
type DefaultProps = {
_id: string;
name: string;
} & string;
const FileSelectAutoComplete = 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.name.toLowerCase().match(query.toLowerCase())
: i.toString().toLowerCase().match(query.toLowerCase())
))}
itemText={(item) => `${item.name || item}`}
itemKey={(item) => `${item._id?.toString() || item.name || item}`}
renderItem={(item) => item.name || item._id || item} // TODO icon
allowEmptyQuery
{...props}
/>
));
FileSelectAutoComplete.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),
};
FileSelectAutoComplete.displayName = 'FileSelectAutoComplete';
FileSelectAutoComplete.defaultProps = {
width: '100%',
height: 'auto',
listStyle: {},
multi: false,
selectedKeys: [],
freeSolo: false,
freeSoloConverter: (input) => input,
};
export default FileSelectAutoComplete;
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoCompleteFC from './components/AutoComplete';
@@ -64,7 +64,7 @@ export default class AutoComplete extends DOMAttachedObject {
attach() {
const value = this.$dom.val();
ReactDOM.render(
ReactDOM.createRoot(this.container).render(
<AutoCompleteFC
ref={(ref) => { this.ref = ref; }}
height="34px"
@@ -76,7 +76,6 @@ export default class AutoComplete extends DOMAttachedObject {
multi={this.options.multi}
freeSolo={this.options.multi}
/>,
this.container,
);
}
@@ -1,7 +1,7 @@
import 'jquery.easing';
import * as React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import { Popover } from '@blueprintjs/core';
import { AutoloadPage } from 'vj/misc/Page';
import CommentBox from 'vj/components/discussion/CommentBox';
@@ -256,7 +256,7 @@ const commentsPage = new AutoloadPage('commentsPage', () => {
const canUseReaction = $('[data-op="react"]').length > 0;
$('[data-op="react"]').each((i, e) => {
ReactDOM.render(<Reaction payload={$(e).data('form')} ele={$(e).closest('.media__body').find('.reactions')} />, e);
ReactDOM.createRoot(e).render(<Reaction payload={$(e).data('form')} ele={$(e).closest('.media__body').find('.reactions')} />);
});
$(document).on('click', '.reaction', async (e) => {
if (!canUseReaction) {
@@ -62,7 +62,7 @@ export default class VjEditor extends DOMAttachedObject {
? monaco.editor.getModel(monaco.Uri.parse(model))
|| monaco.editor.createModel(value, language === 'auto' ? undefined : language, monaco.Uri.parse(model))
: model;
this.model.setValue(value);
if (!this.options.model) this.model.setValue(value);
const cfg: import('../monaco').default.editor.IStandaloneEditorConstructionOptions = {
theme,
lineNumbers: 'on',
@@ -38,15 +38,15 @@ function testElementHotkey(hotkey, $element, attr) {
const [defStr, trigger] = singleDef.split(':');
if (isHotkeyMatch(hotkey, defStr)) {
switch (trigger) {
case 'submit':
$element.closest('form').trigger('submit');
break;
case undefined:
$element.trigger('click');
break;
default:
$element.trigger(trigger);
break;
case 'submit':
$element.closest('form').trigger('submit');
break;
case undefined:
$element.trigger('click');
break;
default:
$element.trigger(trigger);
break;
}
}
});
@@ -42,6 +42,9 @@
opacity: 0.85
a.menu__link, button.menu__link
&.half
padding-right: 0
width: 50%
&.disabled
cursor: default
opacity: 0.5
@@ -1,67 +0,0 @@
import { AutoloadPage } from 'vj/misc/Page';
import { InfoDialog } from 'vj/components/dialog';
import VjNotification from 'vj/components/notification/index';
import { FLAG_ALERT } from 'vj/constant/message';
import i18n from 'vj/utils/i18n';
import tpl from 'vj/utils/tpl';
const messagePage = new AutoloadPage('messagePage', (pagename) => {
if (pagename === 'home_messages' || !UserContext._id) return;
if (UserContext.unreadMsg) {
new VjNotification({
type: 'info',
message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`),
duration: 5000,
action: () => window.open('/home/messages', '_blank'),
}).show();
}
const onmessage = (message) => {
const msg = JSON.parse(message);
console.log('Received message', msg);
if (msg.mdoc.flag & FLAG_ALERT) {
// Is alert
return new InfoDialog({
cancelByClickingBack: false,
$body: tpl`
<div class="typo">
${{ templateRaw: true, html: i18n('Message from {0}:', `<div data-user>${msg.mdoc.from}</div>`) }}
<p>${i18n(msg.mdoc.content)}</p>
</div>`,
}).open();
}
if (msg.udoc._id === 1 && msg.mdoc.flag & 4) {
// System notification
return new VjNotification({
message: i18n('You received a system message, click here to view.'),
duration: 15000,
action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'),
}).show();
}
// Is message
return new VjNotification({
title: msg.udoc.uname,
avatar: msg.udoc.avatarUrl,
message: msg.mdoc.content,
duration: 15000,
action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'),
}).show();
};
const path = new URL('/home/messages-conn', window.location.href).toString();
const worker = new SharedWorker('/sharedworker.js', { name: 'Hydro Messages Shared Worker' });
worker.port.start();
worker.port.postMessage({ type: 'init', path, cookie: document.cookie });
worker.port.onmessage = (e) => {
if (e.data.type !== 'message') return;
if (document.hidden) {
worker.port.postMessage({ type: 'fail' });
} else {
worker.port.postMessage({ type: 'success' });
onmessage(e.data.payload);
}
};
});
export default messagePage;
@@ -0,0 +1,122 @@
import { AutoloadPage } from 'vj/misc/Page';
import { InfoDialog } from 'vj/components/dialog';
import VjNotification from 'vj/components/notification/index';
import ReconnectingWebsocket from 'reconnecting-websocket';
import { FLAG_ALERT } from 'vj/constant/message';
import i18n from 'vj/utils/i18n';
import tpl from 'vj/utils/tpl';
import { nanoid } from 'nanoid';
const onmessage = (msg) => {
console.log('Received message', msg);
if (msg.mdoc.flag & FLAG_ALERT) {
// Is alert
return new InfoDialog({
cancelByClickingBack: false,
$body: tpl`
<div class="typo">
${{ templateRaw: true, html: i18n('Message from {0}:', `<div data-user>${msg.mdoc.from}</div>`) }}
<p>${i18n(msg.mdoc.content)}</p>
</div>`,
}).open();
}
// Is message
return new VjNotification({
...(msg.udoc._id === 1 && msg.mdoc.flag & 4)
? { message: i18n('You received a system message, click here to view.') }
: {
title: msg.udoc.uname,
avatar: msg.udoc.avatarUrl,
message: msg.mdoc.content,
},
duration: 15000,
action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'),
}).show();
};
const messagePage = new AutoloadPage('messagePage', (pagename) => {
if (pagename === 'home_messages' || !UserContext._id) return;
if (UserContext.unreadMsg) {
new VjNotification({
type: 'info',
message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`),
duration: 5000,
action: () => window.open('/home/messages', '_blank'),
}).show();
}
if (!window.BroadcastChannel) {
console.error('BoardcastChannel not supported');
return;
}
let isMaster = false;
const selfId = nanoid();
const channel = new BroadcastChannel('hydro-messages');
const pages = JSON.parse(localStorage.getItem('pages') || '[]') as string[];
pages.push(selfId);
pages.sort();
localStorage.setItem('pages', JSON.stringify(pages));
window.onunload = () => {
const c = JSON.parse(localStorage.getItem('pages') || '[]') as string[];
localStorage.setItem('pages', JSON.stringify(c.filter((i) => i !== selfId)));
if (!isMaster) return;
localStorage.removeItem('page.master');
channel?.postMessage({ type: 'master' });
};
function asMaster() {
console.log('init as master');
isMaster = true;
localStorage.setItem('page.master', selfId);
const masterChannel = new BroadcastChannel('hydro-messages');
const url = new URL('/home/messages-conn', window.location.href.replace('http', 'ws'));
// TODO handle a better way for cookie
url.searchParams.append('sid', document.cookie);
const sock = new ReconnectingWebsocket(url.toString());
const pending = {};
sock.onopen = () => console.log('Connected');
sock.onerror = console.error;
sock.onclose = (...args) => console.log('Closed', ...args);
sock.onmessage = async (message) => {
if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message);
const payload = JSON.parse(message.data);
const id = nanoid();
masterChannel.postMessage({ type: 'message', id, payload });
const success = await new Promise<boolean>((resolve) => {
pending[id] = resolve;
setTimeout(() => {
delete pending[id];
resolve(false);
}, 1000);
});
if (!success && window.Notification?.permission === 'granted') {
const notification = new window.Notification(
payload.udoc.uname || 'Hydro Notification',
{
icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png',
body: payload.mdoc.content,
},
);
notification.onclick = () => window.open('/home/messages');
}
};
masterChannel.onmessage = (msg) => {
if (msg.data.type === 'message-push') pending[msg.data.id]?.(true);
};
}
channel.onmessage = (msg) => {
if (msg.data.type === 'message' && !document.hidden) {
channel.postMessage({ type: 'message-push', id: msg.data.id });
onmessage(msg.data.payload);
}
if (msg.data.type === 'master') {
const c = JSON.parse(localStorage.getItem('pages') || '[]') as string[];
if (c[0] === selfId) asMaster();
}
};
if (!localStorage.getItem('page.master')) asMaster();
});
export default messagePage;
@@ -21,14 +21,14 @@ function scrollToViewport() {
export default function reducer(state = null, action) {
switch (action.type) {
case 'DIALOGUES_SWITCH_TO': {
scrollToViewport();
return action.payload;
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
return action.meta.dialogueId;
}
default:
return state;
case 'DIALOGUES_SWITCH_TO': {
scrollToViewport();
return action.payload;
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
return action.meta.dialogueId;
}
default:
return state;
}
}
@@ -2,52 +2,52 @@ import _ from 'lodash';
export default function reducer(state = {}, action) {
switch (action.type) {
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const { messages } = action.payload;
return { ...state, ..._.keyBy(messages, 'udoc._id') };
}
case 'DIALOGUES_CREATE': {
const { user } = action.payload;
if (state[user._id]) return state;
return {
...state,
[user._id]: {
_id: user._id,
udoc: { ...user },
messages: [],
},
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const id = action.meta.dialogueId;
const { mdoc } = action.payload;
return {
...state,
[id]: {
...state[id],
messages: [
...state[id].messages,
mdoc,
],
},
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { mdoc, udoc } = action.payload;
const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from;
return {
...state,
[to]: {
...state[to] || {},
udoc,
messages: [
...state[to]?.messages || [],
mdoc,
],
},
};
}
default:
return state;
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const { messages } = action.payload;
return { ...state, ..._.keyBy(messages, 'udoc._id') };
}
case 'DIALOGUES_CREATE': {
const { user } = action.payload;
if (state[user._id]) return state;
return {
...state,
[user._id]: {
_id: user._id,
udoc: { ...user },
messages: [],
},
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const id = action.meta.dialogueId;
const { mdoc } = action.payload;
return {
...state,
[id]: {
...state[id],
messages: [
...state[id].messages,
mdoc,
],
},
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { mdoc, udoc } = action.payload;
const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from;
return {
...state,
[to]: {
...state[to] || {},
udoc,
messages: [
...state[to]?.messages || [],
mdoc,
],
},
};
}
default:
return state;
}
}
@@ -2,42 +2,42 @@ import _ from 'lodash';
export default function reducer(state = {}, action) {
switch (action.type) {
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const dialogues = action.payload.messages;
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) };
}
case 'DIALOGUES_CREATE': {
const { user } = action.payload;
return {
...state,
[user._id]: '',
};
}
case 'DIALOGUES_INPUT_CHANGED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: action.payload,
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: '',
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { udoc } = action.payload;
if (!state[udoc._id]) {
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const dialogues = action.payload.messages;
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) };
}
case 'DIALOGUES_CREATE': {
const { user } = action.payload;
return {
...state,
[udoc._id]: '',
[user._id]: '',
};
}
return state;
}
default:
return state;
case 'DIALOGUES_INPUT_CHANGED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: action.payload,
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: '',
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { udoc } = action.payload;
if (!state[udoc._id]) {
return {
...state,
[udoc._id]: '',
};
}
return state;
}
default:
return state;
}
}
@@ -3,47 +3,47 @@ import Notification from 'vj/components/notification';
export default function reducer(state = {}, action) {
switch (action.type) {
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const dialogues = action.payload.messages;
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) };
}
case 'DIALOGUES_CREATE': {
const { user } = action.payload;
return {
...state,
[user._id]: false,
};
}
case 'DIALOGUES_POST_SEND_PENDING': {
const id = action.meta.dialogueId;
return {
...state,
[id]: true,
};
}
case 'DIALOGUES_POST_SEND_REJECTED': {
Notification.error(action.payload.message);
const id = action.meta.dialogueId;
return {
...state,
[id]: false,
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: false,
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { udoc } = action.payload;
return {
...state,
[udoc._id]: false,
};
}
default:
return state;
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const dialogues = action.payload.messages;
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) };
}
case 'DIALOGUES_CREATE': {
const { user } = action.payload;
return {
...state,
[user._id]: false,
};
}
case 'DIALOGUES_POST_SEND_PENDING': {
const id = action.meta.dialogueId;
return {
...state,
[id]: true,
};
}
case 'DIALOGUES_POST_SEND_REJECTED': {
Notification.error(action.payload.message);
const id = action.meta.dialogueId;
return {
...state,
[id]: false,
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: false,
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { udoc } = action.payload;
return {
...state,
[udoc._id]: false,
};
}
default:
return state;
}
}
@@ -1,4 +1,4 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { setDiagnosticsOptions } from 'monaco-yaml';
/** @type {Record<string, import('json-schema').JSONSchema7Definition>} */
const problemConfigSchemaDef = {
@@ -8,6 +8,9 @@ const problemConfigSchemaDef = {
properties: {
input: { type: 'string' },
output: { type: 'string' },
time: { $ref: '#/def/time' },
memory: { $ref: '#/def/memory' },
score: { $ref: '#/def/score', description: 'score' },
},
required: ['input'],
additionalProperties: false,
@@ -28,7 +31,7 @@ const problemConfigSchemaDef = {
additionalProperties: false,
},
time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' },
memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kmg])b?$' },
memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kKmMgG])[bB]?$' },
score: { type: 'integer', maximum: 100, minimum: 1 },
};
@@ -37,7 +40,7 @@ const problemConfigSchema = {
type: 'object',
def: problemConfigSchemaDef,
properties: {
redirect: { type: 'string', pattern: '[0-9a-z_-]+\\/[0-9]+' },
redirect: { type: 'string', pattern: '[0-9a-zA-Z_-]+\\/[0-9]+' },
key: { type: 'string', pattern: '[0-9a-f]{32}' },
type: { enum: ['default', 'interactive', 'submit_answer', 'objective', 'remote_judge'] },
subType: { type: 'string' },
@@ -75,8 +78,7 @@ const problemConfigSchema = {
additionalProperties: false,
};
// @ts-ignore
monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({
setDiagnosticsOptions({
validate: true,
enableSchemaRequest: true,
hover: true,
@@ -86,7 +88,7 @@ monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({
{
uri: 'https://hydro.js.org/schema/problemConfig.json',
fileMatch: ['hydro://problem/file/config.yaml'],
schema: problemConfigSchema,
schema: problemConfigSchema as any,
},
{
uri: `${UiContext.cdn_prefix}manage/setting/schema.json`,
@@ -1,6 +1,7 @@
import React from 'react';
import { sortBy } from 'lodash';
import request from 'vj/utils/request';
import Notification from 'vj/components/notification';
export default class ObjectiveContainer extends React.PureComponent {
constructor(args) {
@@ -0,0 +1,69 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import type { RootState } from './reducer/index';
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
export function FormItem({
columns, label, children, helpText = '', disableLabel = false, ...props
}) {
return (
<div {...props} className={`${columns && `medium-${columns}`} columns form__item`}>
<label htmlFor={`${label}-form`}>
{!disableLabel && i18n(label)}
{children}
{helpText && (<p className="help-text">{i18n(helpText)}</p>)}
</label>
</div>
);
}
type KeyType<K, T = string | number> = {
[Q in keyof K]: K[Q] extends T ? Q : never;
}[keyof K];
export function ManagedInput({ placeholder, formKey }: { placeholder?: string, formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const dispatch = useDispatch();
return (
<input
placeholder={i18n(placeholder)}
value={value || ''}
type={typeof value === 'number' ? 'number' : 'text'}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="textbox"
/>
);
}
export function ManagedSelect({ options, formKey }: { options: string[], formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const dispatch = useDispatch();
return (
<select
value={value || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="select"
>
{options.map((o) => (<option id={o} key={o}>{o}</option>))}
</select>
);
}
export function SingleFileSelect({ formKey }: { formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const Files = useSelector((state: RootState) => state.testdata);
const dispatch = useDispatch();
return (
<FileSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[value]}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })}
/>
);
}
@@ -0,0 +1,162 @@
import React from 'react';
import type { editor } from 'monaco-editor';
import { connect } from 'react-redux';
import { load } from 'vj/components/monaco/loader';
import Editor from 'vj/components/editor';
import { diffLines } from 'diff';
import yaml from 'js-yaml';
import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface';
const mapStateToProps = (state) => ({
config: state.config,
});
const mapDispatchToProps = (dispatch) => ({
handleUpdateCode: (code) => {
dispatch({
type: 'CONFIG_CODE_UPDATE',
payload: code,
});
},
});
interface Props {
config: object;
handleUpdateCode: Function;
}
const configKey = [
'type', 'subType', 'target', 'score', 'time',
'memory', 'filename', 'checker_type', 'checker', 'interactor',
'user_extra_files', 'judge_extra_files', 'detail', 'outputs', 'redirect',
'cases', 'subtasks', 'langs',
];
const subtasksKey = [
'time', 'memory', 'score', 'if', 'id',
'type', 'cases',
];
const casesKey = ['time', 'memory', 'input', 'output'];
export function configYamlFormat(config: ProblemConfigFile) {
const formatConfig: ProblemConfigFile = {};
configKey.forEach((key) => {
if (config[key] !== undefined) {
if (key === 'subType' && ['single', 'multi'].includes(config[key]) && config.type !== 'submit_answer') return;
if (key === 'checker_type' && config.type !== 'default') return;
if (key === 'checker'
&& (['default', 'strict'].includes(formatConfig.checker_type) || !formatConfig.checker_type)) return;
if (key === 'interactor' && config.type !== 'interactive') return;
if (key === 'subtasks') {
formatConfig[key] = [];
config[key].forEach((subtask) => {
const formatSubtask: object = {};
subtasksKey.forEach((subtaskKey) => {
if (subtask && subtask[subtaskKey] !== undefined) {
formatSubtask[subtaskKey] = subtask[subtaskKey];
}
});
formatConfig[key].push(formatSubtask);
});
} else if (key === 'cases') {
formatConfig[key] = [];
config[key].forEach((caseItem) => {
const formatCase: TestCaseConfig = {
time: '1000ms', memory: '256MB', input: '', output: '',
};
casesKey.forEach((caseKey) => {
if (caseItem[caseKey] !== undefined) formatCase[caseKey] = caseItem[caseKey];
else delete formatCase[caseKey];
});
formatConfig[key].push(formatCase);
});
} else formatConfig[key] = config[key];
}
});
if (formatConfig.type === 'objective') {
Object.keys(formatConfig).filter((i) => !['type', 'outputs'].includes(i)).forEach((i) => delete formatConfig[i]);
formatConfig.outputs = formatConfig.outputs || [];
}
Object.keys(formatConfig).filter((i) => i.startsWith('__')).forEach((i) => delete formatConfig[i]);
return formatConfig;
}
export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent<Props> {
disposable = [];
containerElement: HTMLElement;
private __preventUpdate = false;
private __preventFormat = false;
editor: editor.IStandaloneCodeEditor;
model: editor.ITextModel;
vjEditor: Editor;
async componentDidMount() {
const { monaco } = await load(['yaml']);
const uri = monaco.Uri.parse('hydro://problem/file/config.yaml');
this.model = monaco.editor.createModel(yaml.dump(configYamlFormat(this.props.config)), 'yaml', uri);
this.vjEditor = Editor.getOrConstruct($(this.containerElement), {
language: 'yaml',
model: this.model,
onChange: (value: string) => {
this.__preventUpdate = true;
if (!this.__preventFormat) this.props.handleUpdateCode(value);
this.__preventUpdate = false;
},
}) as Editor;
this.editor = this.vjEditor.editor;
}
componentDidUpdate(prevProps) {
if (this.__preventUpdate || !this.model) return;
if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) {
this.__preventFormat = true;
const curValue = this.model.getValue();
const diff = diffLines(curValue, yaml.dump(configYamlFormat(this.props.config)));
const ops = [];
let cursor = 1;
for (const line of diff) {
if (line.added) {
let range = this.model.getFullModelRange();
range = range.setStartPosition(cursor, 0);
range = range.setEndPosition(cursor, 0);
ops.push({ range, text: line.value });
} else if (line.removed) {
let range = this.model.getFullModelRange();
range = range.setStartPosition(cursor, 0);
cursor += line.count;
range = range.setEndPosition(cursor, 0);
ops.push({ range, text: '' });
} else cursor += line.count;
}
this.model.pushEditOperations([], ops, undefined);
this.__preventFormat = false;
}
}
componentWillUnmount() {
if (this.vjEditor) this.vjEditor.destory();
if (this.model) this.model.dispose();
if (this.editor) this.editor.dispose();
this.disposable.map((i) => i.dispose());
}
assignRef = (component) => {
this.containerElement = component;
};
render() {
return (
<div
ref={this.assignRef}
style={{
minHeight: '500px',
height: '100%',
width: '100%',
}}
className="ConfigMonacoEditor"
>
</div>
);
}
});
@@ -0,0 +1,128 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import {
Card, Tab, Tabs, InputGroup, Tag,
} from '@blueprintjs/core';
import type { RootState } from './reducer/index';
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
import { FormItem } from './BasicForm';
import ProblemType from './ProblemType';
import { TaskConfig } from './SubtaskTable';
function FileIOConfig() {
const filename = useSelector((state: RootState) => state.config.filename);
const dispatch = useDispatch();
return (
<FormItem columns={12} label="FileIOConfig" disableLabel>
<Card style={{ padding: 10 }}>
<div className="row">
<FormItem columns={6} label="FileIO">
<InputGroup
rightElement={<Tag minimal>.in/.out</Tag>}
value={filename || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value });
}}
fill
/>
</FormItem>
</div>
</Card>
</FormItem>
);
}
function ExtraFilesConfig() {
const Files = useSelector((state: RootState) => state.testdata);
const userExtraFiles = useSelector((state: RootState) => state.config.user_extra_files) || [];
const judgeExtraFiles = useSelector((state: RootState) => state.config.judge_extra_files) || [];
const dispatch = useDispatch();
const userRef = React.useRef<any>();
const judgeRef = React.useRef<any>();
React.useEffect(() => {
userRef.current.setSelectedKeys(userExtraFiles);
}, [JSON.stringify(userExtraFiles)]);
React.useEffect(() => {
judgeRef.current.setSelectedKeys(judgeExtraFiles);
}, [JSON.stringify(judgeExtraFiles)]);
return (
<FormItem columns={12} label="ExtraFilesTabs" disableLabel>
<Tabs id="ExtraFilesTabs">
<Tab
id="user_extra_files"
title={i18n('user_extra_files')}
panel={(
<FileSelectAutoComplete
ref={userRef}
data={Files}
selectedKeys={userExtraFiles}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })}
multi
/>
)}
/>
<Tab
id="judge_extra_files"
title={i18n('judge_extra_files')}
panel={(
<FileSelectAutoComplete
ref={judgeRef}
data={Files}
selectedKeys={judgeExtraFiles}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })}
multi
/>
)}
/>
</Tabs>
</FormItem>
);
}
function LangConfig() {
const langs = useSelector((state: RootState) => state.config.langs) || [];
const prefixes = new Set(Object.keys(LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0]));
const data = Object.keys(LANGS).filter((i) => !prefixes.has(i))
.map((i) => ({ name: LANGS[i].display, _id: i }));
const dispatch = useDispatch();
const ref = React.useRef<any>();
const selectedKeys = langs.filter((i) => !prefixes.has(i));
React.useEffect(() => {
ref.current.setSelectedKeys(selectedKeys);
}, [JSON.stringify(selectedKeys)]);
return (
<FormItem columns={12} label="langs">
<CustomSelectAutoComplete
ref={ref}
data={data}
placeholder={i18n('Unlimited')}
selectedKeys={selectedKeys}
onChange={(val) => {
const value = val.split(',');
value.push(...Array.from(new Set(value.filter((i) => i.includes('.')).map((i) => i.split('.')[0]))));
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'langs', value });
}}
multi
/>
</FormItem>
);
}
export default function ProblemConfigForm() {
const Type = useSelector((state: RootState) => state.config.type);
return (
<div className="row problem-config-form">
<ProblemType />
{Type === 'default' && <FileIOConfig />}
{!['submit_answer', 'objective'].includes(Type) && (
<>
<TaskConfig />
<ExtraFilesConfig />
<LangConfig />
</>
)}
</div>
);
}
@@ -0,0 +1,129 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import {
Card, Switch, Tab, Tabs,
} from '@blueprintjs/core';
import type { RootState } from './reducer/index';
import { FormItem, ManagedSelect, SingleFileSelect } from './BasicForm';
export default function ProblemType() {
const Type = useSelector((state: RootState) => state.config.type);
const checkerType = useSelector((state: RootState) => state.config.checker_type);
const filename = useSelector((state: RootState) => state.config.filename);
const subType = useSelector((state: RootState) => state.config.subType);
const dispatch = useDispatch();
const dispatcher = (base) => (value) => dispatch({ ...base, value });
return (
<FormItem columns={12} label="" disableLabel>
<Card style={{ padding: 10 }}>
<Tabs
id="ProblemTypeTabs"
selectedTabId={Type}
onChange={dispatcher({ type: 'CONFIG_FORM_UPDATE', key: 'type' })}
defaultSelectedTabId="default"
renderActiveTabPanelOnly
>
<span className="bp4-tab">{i18n('Problem Type')}</span>
<Tabs.Expander />
<Tab
id="default"
title={i18n('problem_type.default')}
panel={(
<Tabs
id="CheckerTypeTabs"
selectedTabId={
['strict', 'default'].includes(checkerType) || !checkerType
? 'default' : (checkerType !== 'testlib' ? 'other' : 'testlib')
}
onChange={dispatcher({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type' })}
renderActiveTabPanelOnly
>
<span className="bp4-tab">{i18n('CheckerType')}</span>
<Tabs.Expander />
<Tab
id="default"
title={i18n('default')}
panel={(
<div className="row">
<FormItem columns={12} label="Config">
<Switch
checked={checkerType !== 'strict'}
label={i18n('Ignore trailing space and enter.')}
onChange={() => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' });
}}
/>
</FormItem>
</div>
)}
/>
{['testlib', 'other'].map((i) => (
<Tab
id={i}
title={i}
key={i}
panel={(
<div className="row">
{i === 'other' && (
<FormItem columns={6} label="Interface">
<ManagedSelect options={['syzoj', 'hustoj', 'qduoj', 'lemon']} formKey="checker_type" />
</FormItem>
)}
<FormItem columns={6} label="Checker">
<SingleFileSelect formKey="checker" />
</FormItem>
</div>
)}
/>
))}
</Tabs>
)}
/>
<Tab
id="interactive"
title={i18n('problem_type.interactive')}
panel={(
<div className="row">
<FormItem columns={6} label="Interactor">
<SingleFileSelect formKey="interactor" />
</FormItem>
</div>
)}
/>
<Tab
id="submit_answer"
title={i18n('problem_type.submit_answer')}
panel={(
<div className="row">
<FormItem columns={6} label="Config" disableLabel>
<Switch
checked={subType === 'multi'}
label={i18n('Multi-file')}
onChange={() => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'subType', value: subType === 'multi' ? 'single' : 'multi' });
}}
/>
</FormItem>
<FormItem columns={6} label="Filename">
<input
defaultValue={filename || '#.txt'}
placeholder="#.txt"
disabled={subType !== 'multi'}
onChange={(ev) => dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }))}
className="textbox"
/>
</FormItem>
</div>
)}
/>
<Tab
id="objective"
title={i18n('problem_type.objective')}
panel={(<p>{i18n('Unsupported configure this type of problem. Please refer to the documentation.')}</p>)}
/>
</Tabs>
</Card>
</FormItem>
);
}
@@ -0,0 +1,186 @@
import { Card, NumericInput, Tag } from '@blueprintjs/core';
import { pick, isEqual } from 'lodash';
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { SubtaskConfig } from 'hydrooj/src/interface';
import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common';
import i18n from 'vj/utils/i18n';
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
import { FormItem } from './BasicForm';
import { RootState } from './reducer';
import { CasesTable } from './TestCasesTable';
const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b);
export function SubtasksIds({ index }) {
const ids = useSelector((state: RootState) => state.config.subtasks.map((i) => (i ? i.id : undefined)).filter((i) => i !== undefined).join(','));
const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if);
const dispatch = useDispatch();
return (
<tr>
<td style={{ textAlign: 'center' }}>if</td>
<td colSpan={4}>
<CustomSelectAutoComplete
data={ids.split(',').filter((i) => i.length)}
selectedKeys={subtaskIf?.map(String) || []}
onChange={(val) => dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','),
})}
multi
/>
</td>
</tr>
);
}
export function SubtasksTable({ index }) {
const keys = ['id', 'type', 'score', 'time', 'memory'];
const subtask = useSelector((state: RootState) => pick(state.config.subtasks[index], keys), eq);
const defaultTime = useSelector((state: RootState) => state.config.time);
const defaultMemory = useSelector((state: RootState) => state.config.memory);
const dispatch = useDispatch();
const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value;
if (value === 0) value = '';
if (value && suffix) value += suffix;
dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value,
});
};
if (!subtask || Object.keys(subtask).length === 0) {
return (
<Card style={{ padding: 0 }}>
<span>Subtasks #{index + 1} </span>
<p>{i18n('Failed to parse subtask.')}</p>
</Card>
);
}
return (
<Card style={{ padding: 0 }}>
<span>Subtasks #{index + 1} </span>
<a onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' })}><span className="icon icon-add"></span></a>
<a
style={index === 0 ? { display: 'none' } : {}}
onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' })}
>
<span className="icon icon-delete"></span>
</a>
<table className="data-table">
<thead>
<tr>
<th>#</th>
<th>Score</th>
<th>Type</th>
<th>Time</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
<tr>
{['id', 'score'].map((key) => (
<td key={key}>
<input
value={subtask[key] ?? ''}
onChange={dispatcher(key)}
type="number"
className="textbox"
/>
</td>
))}
<td>
<select
value={subtask.type ?? 'min'}
onChange={dispatcher('type')}
className="select"
>
{['min', 'max', 'sum'].map((i) => (<option value={i} key={i}>{i}</option>))}
</select>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>ms</Tag>}
value={subtask.time ? parseTimeMS(subtask.time, false).toString() : ''}
placeholder={parseTimeMS(defaultTime || '1000ms', false).toString()}
onValueChange={dispatcher('time', 'ms')}
buttonPosition="none"
fill
/>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>MB</Tag>}
value={subtask.memory ? parseMemoryMB(subtask.memory, false).toString() : ''}
placeholder={parseMemoryMB(defaultMemory || '256m', false).toString()}
onValueChange={dispatcher('memory', 'MB')}
buttonPosition="none"
fill
/>
</td>
</tr>
<SubtasksIds index={index} />
</tbody>
</table>
<CasesTable index={index} />
</Card>
);
}
function GlobalTaskConfig() {
const time = useSelector((state: RootState) => state.config.time);
const memory = useSelector((state: RootState) => state.config.memory);
const dispatch = useDispatch();
const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value;
if (value === 0) value = '';
if (value && suffix) value += suffix;
dispatch({ type: 'CONFIG_FORM_UPDATE', key, value });
};
return (
<>
<FormItem columns={6} label="Time">
<NumericInput
rightElement={<Tag minimal>ms</Tag>}
value={time ? parseTimeMS(time, false).toString() : ''}
placeholder={parseTimeMS('1000ms').toString()}
onValueChange={dispatcher('time', 'ms')}
buttonPosition="none"
fill
/>
</FormItem>
<FormItem columns={6} label="Memory">
<NumericInput
rightElement={<Tag minimal>MB</Tag>}
value={memory ? parseMemoryMB(memory, false).toString() : ''}
placeholder={parseMemoryMB('256m').toString()}
onValueChange={dispatcher('memory', 'MB')}
buttonPosition="none"
fill
/>
</FormItem>
</>
);
}
export function TaskConfig() {
const len = useSelector((state: RootState) => state.config.subtasks?.length);
const dispatch = useDispatch();
return (
<FormItem columns={12} label="Task Settings">
<div className="row">
<GlobalTaskConfig />
<FormItem columns={12} label="TestCases" disableLabel>
{len > 0
? [...Array(len).keys()].map((i) => <SubtasksTable index={i} key={i} />)
: (
<>
<span>Subtasks # </span>
<a onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: 0, key: 'add' })}>
<span className="icon icon-add"></span>
</a>
</>
)}
</FormItem>
</div>
</FormItem>
);
}
@@ -0,0 +1,128 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import { isEqual } from 'lodash';
import { Tag, NumericInput } from '@blueprintjs/core';
import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common';
import type { SubtaskConfig } from 'hydrooj/src/interface';
import type { RootState } from './reducer/index';
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b);
const eqArr = (a: any[], b: any[]) => isEqual(a, b);
export function TestCaseEntry({ index, subindex }) {
const testcase = useSelector((state: RootState) => state.config.subtasks[index].cases[subindex], eq);
const Files = useSelector((state: RootState) => state.testdata, eqArr);
const defaultTime = useSelector((state: RootState) => state.config.subtasks[index].time || state.config.time);
const defaultMemory = useSelector((state: RootState) => state.config.subtasks[index].memory || state.config.memory);
const dispatch = useDispatch();
const dispatcher = (casesKey: string, valueSuffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
let value = typeof ev !== 'object' ? ev : ev.currentTarget.value;
if (value === 0) value = '';
if (valueSuffix && value) value += valueSuffix;
dispatch({
type: 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-edit',
casesId: subindex,
casesKey,
value,
});
};
if (!testcase || Object.keys(testcase).length === 0) {
return (
<tr><td colSpan={5}>{i18n('Failed to parse testcase.')}</td></tr>
);
}
return (
<tr>
<td>
<NumericInput
rightElement={<Tag minimal>ms</Tag>}
value={testcase.time ? parseTimeMS(testcase.time, false).toString() : ''}
placeholder={parseTimeMS(defaultTime || '1000ms', false).toString()}
onValueChange={dispatcher('time', 'ms')}
buttonPosition="none"
fill
/>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>MB</Tag>}
value={testcase.memory ? parseMemoryMB(testcase.memory, false).toString() : ''}
placeholder={parseMemoryMB(defaultMemory || '256m', false).toString()}
onValueChange={dispatcher('memory', 'MB')}
buttonPosition="none"
fill
/>
</td>
{['input', 'output'].map((t) => (
<td key={t}>
<FileSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[testcase[t]]}
onChange={dispatcher(t)}
/>
</td>
))}
<td className="col--operation">
<a
onClick={() => dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex,
})}
><span className="icon icon-close"></span>
</a>
</td>
</tr>
);
}
export function CasesTable({ index }) {
const len = useSelector((state: RootState) => state.config.subtasks[index].cases?.length);
const dispatch = useDispatch();
return (
<table className="data-table">
<thead style={{ display: 'none' }}>
<tr>
<th>{i18n('Time')}</th>
<th>{i18n('Memory')}</th>
<th>{i18n('Input')}</th>
<th>{i18n('Output')}</th>
<th className="col--operation">
<a
onClick={() => dispatch({
type: 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-add',
value: { input: '', output: '' },
})}
><span className="icon icon-add" />
</a>
</th>
</tr>
</thead>
<tbody>
<tr className="thead">
<td>{i18n('Time')}</td>
<td>{i18n('Memory')}</td>
<td>{i18n('Input')}</td>
<td>{i18n('Output')}</td>
<td className="col--operation">
<a
onClick={() => dispatch({
type: 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-add',
value: { input: '', output: '' },
})}
><span className="icon icon-add" />
</a>
</td>
</tr>
{len > 0 && [...Array(len).keys()].map((i) => <TestCaseEntry index={index} subindex={i} key={i} />)}
</tbody>
</table>
);
}
@@ -0,0 +1,69 @@
import yaml from 'js-yaml';
import type { ProblemConfigFile } from 'hydrooj/src/interface';
import { cloneDeep } from 'lodash';
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common';
type State = ProblemConfigFile & { __loaded: boolean };
export default function reducer(state = { type: 'default', __loaded: false } as State, action): State {
switch (action.type) {
case 'CONFIG_LOAD_FULFILLED': {
return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true };
}
case 'CONFIG_FORM_UPDATE': {
const next = { ...state, [action.key]: action.value };
if (action.key === 'score' && action.value) next.score = +next.score;
if (action.key === 'checker_type' && action.value === 'other') next.checker_type = 'syzoj';
if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key];
return next;
}
case 'CONFIG_CODE_UPDATE': {
try {
return { ...state, ...yaml.load(action.payload) as object };
} catch {
return state;
}
}
case 'CONFIG_AUTOCASES_UPDATE': {
const next = { ...state };
const { subtasks } = action;
for (const subtask of subtasks) {
if (subtask.time === parseTimeMS(state.time || '1s')) delete subtask.time;
if (subtask.memory === parseMemoryMB(state.memory || '256m')) delete subtask.memory;
if (subtask.time) subtask.time += 'ms';
if (subtask.memory) subtask.memory += 'MB';
}
if (subtasks.length === 0) next.subtasks = [];
else {
next.subtasks = subtasks.map((subtask) => (
{ ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } }));
}
return next;
}
case 'CONFIG_SUBTASK_UPDATE': {
const subtasks = cloneDeep(state.subtasks);
const subsubtasks = cloneDeep(state.subtasks[action.id]);
if (action.value !== '' && ['score', 'id'].includes(action.key)) action.value = +action.value;
if (action.key === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i);
if (action.key.split('-')[0] === 'cases') {
if (action.key === 'cases-add') subsubtasks.cases.push(action.value);
else if (action.key === 'cases-edit') {
if (action.value === '') delete subsubtasks.cases[action.casesId][action.casesKey];
else subsubtasks.cases[action.casesId][action.casesKey] = action.value;
} else if (action.key === 'cases-delete') {
subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value);
}
} else if (action.key === 'add') {
return { ...state, subtasks: [...subtasks, { time: state.time || '1s', memory: state.memory || '256m', cases: [] }] };
} else if (action.key === 'delete') return { ...state, subtasks: subtasks.filter((k, v) => v !== action.id) };
else {
if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key];
else subsubtasks[action.key] = action.value;
}
subtasks[action.id] = subsubtasks;
return { ...state, subtasks };
}
default:
return state;
}
}
@@ -0,0 +1,11 @@
import { combineReducers } from 'redux';
import config from './config';
import testdata from './testdata';
const reducer = combineReducers({
config,
testdata,
});
export default reducer;
export type RootState = ReturnType<typeof reducer>;
@@ -0,0 +1,16 @@
export default function reducer(state = [], action) {
switch (action.type) {
case 'CONFIG_LOAD_FULFILLED': {
return state.concat(action.payload.testdata);
}
case 'CONFIG_DELETE_TESTDATA': {
const next = state.filter((i) => i.name !== action.value[0]);
return next;
}
case 'CONFIG_ADD_TESTDATA': {
return state.concat([action.value]);
}
default:
return state;
}
}
@@ -5,36 +5,36 @@ export default function reducer(state = {
items: {},
}, action) {
switch (action.type) {
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
const { rdocs } = action.payload;
return {
...state,
rows: _.map(rdocs, '_id'),
items: _.keyBy(rdocs, '_id'),
};
}
case 'SCRATCHPAD_RECORDS_PUSH': {
const { rdoc } = action.payload;
const rows = [...state.rows];
if (!rows.includes(rdoc._id)) {
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
const { rdocs } = action.payload;
return {
...state,
rows: _.map(rdocs, '_id'),
items: _.keyBy(rdocs, '_id'),
};
}
case 'SCRATCHPAD_RECORDS_PUSH': {
const { rdoc } = action.payload;
const rows = [...state.rows];
if (!rows.includes(rdoc._id)) {
return {
...state,
rows: [rdoc._id, ...state.rows],
items: {
...state.items,
[rdoc._id]: rdoc,
},
};
}
return {
...state,
rows: [rdoc._id, ...state.rows],
items: {
...state.items,
[rdoc._id]: rdoc,
},
};
}
return {
...state,
items: {
...state.items,
[rdoc._id]: rdoc,
},
};
}
default:
return state;
default:
return state;
}
}
@@ -19,93 +19,93 @@ export default function reducer(state = {
isPosting: false,
}, action) {
switch (action.type) {
case 'SCRATCHPAD_UI_CHANGE_SIZE': {
const { uiElement, size } = action.payload;
return {
...state,
[uiElement]: {
...state[uiElement],
size,
},
};
}
case 'SCRATCHPAD_UI_SET_VISIBILITY': {
const { uiElement, visibility } = action.payload;
localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString());
return {
...state,
[uiElement]: {
...state[uiElement],
visible: visibility,
},
};
}
case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': {
const { uiElement } = action.payload;
localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString());
return {
...state,
[uiElement]: {
...state[uiElement],
visible: !state[uiElement].visible,
},
};
}
case 'SCRATCHPAD_POST_PRETEST_PENDING':
case 'SCRATCHPAD_POST_SUBMIT_PENDING': {
return {
...state,
isPosting: true,
};
}
case 'SCRATCHPAD_POST_PRETEST_FULFILLED':
case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': {
Notification.success(i18n('Submitted.'));
if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) {
state.records.visible = true;
case 'SCRATCHPAD_UI_CHANGE_SIZE': {
const { uiElement, size } = action.payload;
return {
...state,
[uiElement]: {
...state[uiElement],
size,
},
};
}
return {
...state,
isPosting: false,
};
}
case 'SCRATCHPAD_POST_PRETEST_REJECTED':
case 'SCRATCHPAD_POST_SUBMIT_REJECTED': {
Notification.error(action.payload.message);
return {
...state,
isPosting: false,
};
}
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': {
return {
...state,
records: {
...state.records,
isLoading: true,
},
};
}
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': {
Notification.error(action.payload.message);
return {
...state,
records: {
...state.records,
isLoading: false,
},
};
}
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
return {
...state,
records: {
...state.records,
isLoading: false,
},
};
}
default:
return state;
case 'SCRATCHPAD_UI_SET_VISIBILITY': {
const { uiElement, visibility } = action.payload;
localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString());
return {
...state,
[uiElement]: {
...state[uiElement],
visible: visibility,
},
};
}
case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': {
const { uiElement } = action.payload;
localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString());
return {
...state,
[uiElement]: {
...state[uiElement],
visible: !state[uiElement].visible,
},
};
}
case 'SCRATCHPAD_POST_PRETEST_PENDING':
case 'SCRATCHPAD_POST_SUBMIT_PENDING': {
return {
...state,
isPosting: true,
};
}
case 'SCRATCHPAD_POST_PRETEST_FULFILLED':
case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': {
Notification.success(i18n('Submitted.'));
if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) {
state.records.visible = true;
}
return {
...state,
isPosting: false,
};
}
case 'SCRATCHPAD_POST_PRETEST_REJECTED':
case 'SCRATCHPAD_POST_SUBMIT_REJECTED': {
Notification.error(action.payload.message);
return {
...state,
isPosting: false,
};
}
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': {
return {
...state,
records: {
...state.records,
isLoading: true,
},
};
}
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': {
Notification.error(action.payload.message);
return {
...state,
records: {
...state.records,
isLoading: false,
},
};
}
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
return {
...state,
records: {
...state.records,
isLoading: false,
},
};
}
default:
return state;
}
}
@@ -5,7 +5,6 @@ export default class Sock {
const i = new URL(url, window.location.href);
i.protocol = i.protocol.replace('http', 'ws');
this.url = i.toString();
this.closed = false;
this.sock = new ReconnectingWebSocket(this.url);
this.sock.onopen = () => {
console.log('Connected');
@@ -13,17 +12,14 @@ export default class Sock {
};
this.sock.onclose = ({ code, reason }) => {
console.warn('Connection closed, ', code, reason);
if (code >= 4000) this.closed = true;
if (code >= 4000) this.close();
this.onclose?.(code, reason);
};
this.sock.onmessage = (message) => {
if (process.env.NODE_ENV !== 'production') console.log('Sock.onmessage: ', message);
const msg = JSON.parse(message.data);
if (msg.event === 'auth') {
if (msg.error === 'PermissionError' || msg.error === 'PrivilegeError') {
this.close();
}
} else this.onmessage?.(message);
if (msg.error === 'PermissionError' || msg.error === 'PrivilegeError') this.close();
else this.onmessage?.(message);
};
}
@@ -32,7 +28,6 @@ export default class Sock {
}
close() {
this.closed = true;
this.sock?.close?.();
}
}
@@ -51,6 +51,7 @@ export default async function download(filename, targets) {
stream,
};
} catch (e) {
window.captureException?.(e);
stopDownload();
Notification.error(i18n('Download Error', [target.filename, e.toString()]));
}
@@ -144,6 +145,7 @@ export async function downloadProblemSet(pids, name = 'Export') {
}
await download(`${name}.zip`, targets);
} catch (e) {
window.captureException?.(e);
Notification.error(`${e.message} ${e.params?.[0]}`);
}
}
+1
View File
@@ -4,6 +4,7 @@ window.Hydro = {
components: {},
utils: {},
node_modules: {},
version: process.env.VERSION,
};
window.externalModules = {};
+1
View File
@@ -64,6 +64,7 @@ async function load() {
try {
await func(currentPageName, loadPage(1, type));
} catch (e) {
(window as any).captureException?.(e);
Notification.warn(`Failed to call '${type}Loading' of ${page.name}`);
console.error(`Failed to call '${type}Loading' of ${page.name}\n${e.stack}`);
console.error(e);
+22
View File
@@ -86,6 +86,7 @@ Attend Contest: 参加比赛
Attend contests: 参加比赛
Attended: 已参加
author: 作者
Auto Read Tasks: 自动识别任务
Be Copied: 被复制
Begin at: 开始于
Begin Date: 开始日期
@@ -117,6 +118,8 @@ Change Email: 修改电子邮件
Change Password: 修改密码
Change Theme: 切换主题
Check In: 签到
Checker: 比较器
CheckerType: 比较器类型
Chinese: 中文
Choose the background image in your profile page.: 选择您资料页面的背景图片。
Claim homework: 认领作业
@@ -138,6 +141,7 @@ Compile Error: 编译错误
Compilers' Version and Parameters: 编译器版本及参数
Complete: 完成
Completed: 已完成
Config: 配置
Confirm deleting the selected roles?: 您确定删除所选角色吗?
Confirm deleting this comment? Its replies will be deleted as well.: 确认删除这个评论吗?回复会被同时删除。
Confirm deleting this reply?: 确认删除这个回复吗?
@@ -268,6 +272,7 @@ domain: 域
Domain: 域
Don't have an account?: 还没有账户?
Done: 已结束
Download All: 下载全部
Download Dataset: 下载数据集
Download Selected: 下载选中
Download: 下载
@@ -318,7 +323,10 @@ Export as CSV: 导出为 CSV
Export as HTML: 导出为 HTML
Extension (days): 最长延期 (日)
Extension Score Penalty: 延期递交扣分规则
Extra Files Config: 额外文件设置
Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。
Failed to parse subtask.: 无法解析子任务信息。
Failed to parse testcase.: 无法解析测试点信息。
Feedback: 反馈
Field {0} or {1} validation failed.: 字段 {0} 或 {1} 验证失败。
Field {0} validation failed.: 字段 {0} 验证失败。
@@ -382,6 +390,7 @@ Host: 主持人
hour(s): 小时
If enabled, source code will be emailed to you after the submission is accepted.: 如果启用,在您通过题目后,源代码会以 Email 的形式发送给您。
If left blank, the built-in template of the corresponding language will be used.: 若留空,则将使用对应语言的内置代码模板。
Ignore trailing space and enter.: 忽略行末空格与文件尾回车。
Images With Size: 指定大小的图片
Images: 图片
Import from: 导入自
@@ -400,6 +409,7 @@ Information: 信息
Initialize: 初始化
Inline codes: 行内代码
Inline formulas: 行内公式
Input: 输入
Insert Code: 插入代码
Insert Image: 插入图像
Installed Modules: 已安装的模块
@@ -414,12 +424,16 @@ Is rated: 计入排名
It works!: 它成了!
Italic: 斜体
Join: 加入
Judge Config: 评测设置
Judge Status: 评测状态
judge_extra_files: 评测额外文件
judge_playground: 评测游乐场
Judged At: 评测时间
Judged By: 评测机
Judging Queue: 评测队列
Keep current expiration: 保持当前过期设置
Langs Config: 语言设置
langs: 语言
Language: 语言
last active at: 最后活动于
last login at: 最后登录于
@@ -531,6 +545,7 @@ Or, with automatically filled invitation code: 或者,这是可以自动填写
Ordered List: 有序列表
Original Score: 原始分数
OS Info: 系统信息
Output: 输出
Over {0} problems: 超过 {0} 道题目
Owner: 所有者
page.problem_detail.sidebar.show_category: 点击显示
@@ -582,6 +597,8 @@ Problem Solution List: 题解列表
Problem Solution: 题解列表
Problem Tags Visibility: 题目标签可见性
Problem Tags: 题目标签
Problem Type: 题目类型
problem_config: 评测设置
problem_create: 创建题目
problem_edit: 编辑题目
problem_import: 导入题目
@@ -755,6 +772,7 @@ SuperUser: 超级管理员
Support: 支持
Switch account: 切换账户
Tags: 标签
Task Settings: 任务设置
tasks_list: 任务列表
Technical Information: 技术信息
Temporary session: 临时会话
@@ -811,9 +829,11 @@ Uncompleted: 未完成
Unique: 唯一
Unknown field {0}.: 未知字段 {0}。
Unknown: 未知
Unlimited: 无限制
Unordered List: 无序列表
Unstar Topic: 取消收藏
Unstar: 取消收藏
Unsupported configure this type of problem. Please refer to the documentation.: 暂不支持快捷配置此类题目。请参照文档操作。
Update Permission: 更新权限
Update Settings: 更新设置
Update: 更新
@@ -830,12 +850,14 @@ Usage exceeded.: 用量超限。
Use admin specificed: 由管理员指定
Use algorithm calculated: 使用算法计算
Use average of above: 使用上面的平均值
Use Subtasks Mode: 使用子任务模式
User {0} already exists.: 用户 {0} 已存在。
User {0} not found.: 用户 {0} 不存在。
User can join this domain by visiting the following URL: 用户可以访问此链接来加入此域
User ID: 用户 ID
User will no longer be allowed to join the domain after expiration.: 过期后,用户将不能加入此域。
user_detail: 用户
user_extra_files: 编译额外文件
user_login: 登录
user_logout: 登出
user_lostpass: 忘记密码
+31 -30
View File
@@ -1,24 +1,24 @@
{
"name": "@hydrooj/ui-default",
"version": "4.35.0",
"version": "4.37.6",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.js",
"repository": "https://github.com/hydro-dev/Hydro.git",
"preferUnplugged": true,
"scripts": {
"lint": "eslint ."
"lint": "eslint"
},
"devDependencies": {
"@blueprintjs/core": "^3.54.0",
"@fontsource/dm-mono": "^4.5.7",
"@fontsource/fira-code": "^4.5.8",
"@fontsource/inconsolata": "^4.5.4",
"@fontsource/jetbrains-mono": "^4.5.5",
"@fontsource/pt-mono": "^4.5.5",
"@fontsource/roboto-mono": "^4.5.5",
"@fontsource/source-code-pro": "^4.5.6",
"@fontsource/ubuntu-mono": "^4.5.6",
"@blueprintjs/core": "^4.3.0",
"@fontsource/dm-mono": "^4.5.8",
"@fontsource/fira-code": "^4.5.9",
"@fontsource/inconsolata": "^4.5.6",
"@fontsource/jetbrains-mono": "^4.5.8",
"@fontsource/pt-mono": "^4.5.8",
"@fontsource/roboto-mono": "^4.5.7",
"@fontsource/source-code-pro": "^4.5.9",
"@fontsource/ubuntu-mono": "^4.5.9",
"@hydrooj/utils": "workspace:*",
"@milkdown/core": "^6.0.1",
"@milkdown/plugin-clipboard": "^6.0.1",
@@ -42,8 +42,8 @@
"@types/json-schema": "^7.0.11",
"@types/katex": "^0.14.0",
"@types/qrcode": "^1.4.2",
"@types/react-dom": "^18.0.3",
"@types/redux-logger": "^3.0.9",
"@undefined-moe/monaco-yaml": "^2.5.1",
"ansi_up": "^5.1.0",
"autoprefixer": "^9.8.8",
"browser-update": "^3.3.38",
@@ -51,19 +51,19 @@
"chunk-progress-webpack-plugin": "^2.0.1",
"classnames": "^2.3.1",
"clean-webpack-plugin": "^4.0.0",
"clipboard": "^2.0.10",
"clipboard": "^2.0.11",
"copy-webpack-plugin": "^6.4.1",
"css-loader": "^4.3.0",
"diff": "^5.0.0",
"diff-dom": "^4.2.3",
"echarts": "^5.3.2",
"emojis-keywords": "2.0.0",
"emojis-list": "2.1.0",
"esbuild-loader": "^2.18.0",
"eslint": "^8.12.0",
"fancy-log": "^2.0.0",
"file-loader": "^6.2.0",
"friendly-errors-webpack-plugin": "^1.7.0",
"graphiql": "^1.8.3",
"graphiql": "^1.8.9",
"gulp": "^4.0.2",
"gulp-cli": "^2.3.0",
"gulp-iconfont": "^11.0.1",
@@ -76,31 +76,32 @@
"jquery.transit": "^0.9.12",
"material-icons": "^1.10.9",
"mini-css-extract-plugin": "^1.6.2",
"moment": "^2.29.1",
"monaco-editor": "^0.31.1",
"moment": "^2.29.3",
"monaco-editor": "^0.33.0",
"monaco-editor-nls": "^2.0.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"monaco-themes": "^0.4.0",
"nanoid": "^3.3.2",
"monaco-themes": "^0.4.2",
"monaco-yaml": "^4.0.0-alpha.3",
"nanoid": "^3.3.4",
"normalize.css": "^8.0.1",
"nprogress": "^0.2.0",
"pickadate": "^3.6.4",
"plugin-error": "^1.0.1",
"postcss-loader": "^3.0.0",
"prism-themes": "^1.9.0",
"prismjs": "^1.27.0",
"prismjs": "^1.28.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.0",
"queue-microtask": "^1.2.3",
"raw-loader": "^4.0.2",
"react": "^17.0.2",
"react-dnd": "^16.0.0",
"react-dnd-html5-backend": "^16.0.0",
"react-dom": "^17.0.2",
"react-redux": "^7.2.8",
"react": "^18.1.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.1.0",
"react-redux": "^8.0.1",
"react-split-pane": "^0.1.92",
"reconnecting-websocket": "^4.4.0",
"redux": "^4.1.2",
"redux": "^4.2.0",
"redux-logger": "^3.0.6",
"redux-promise-middleware": "^6.1.2",
"redux-thunk": "^2.4.1",
@@ -118,7 +119,7 @@
"timeago-react": "^3.0.4",
"timeago.js": "^4.0.2",
"vinyl-buffer": "^1.0.1",
"web-streams-polyfill": "^3.2.0",
"web-streams-polyfill": "^3.2.1",
"webpack": "^4.46.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-dev-server": "^3.11.3",
@@ -126,13 +127,13 @@
},
"dependencies": {
"esbuild": "0.14.3",
"fs-extra": "^10.0.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"jsesc": "^3.0.2",
"katex": "^0.15.3",
"lodash": "^4.17.21",
"markdown-it": "^12.3.2",
"markdown-it-anchor": "^8.4.1",
"markdown-it": "^13.0.1",
"markdown-it-anchor": "^8.6.4",
"markdown-it-footnote": "^3.0.3",
"markdown-it-imsize": "^2.0.1",
"markdown-it-mark": "^3.0.1",
+2 -2
View File
@@ -2,7 +2,7 @@
import { NamedPage } from 'vj/misc/Page';
import request from 'vj/utils/request';
import React from 'react';
import { render } from 'react-dom';
import ReactDOM from 'react-dom/client';
const Logo = () => <span>Hydro API Console </span>;
const defaultQuery = `\
@@ -43,7 +43,7 @@ const page = new NamedPage('api', async () => {
/>
);
render(<App />, document.getElementById('graphiql'));
ReactDOM.createRoot(document.getElementById('graphiql')).render(<App />);
});
export default page;
+5 -4
View File
@@ -40,12 +40,12 @@ async function handleClickUpload(files) {
const dialog = new Dialog({
$body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
<div class="file-progress bp3-progress-meter" style="width: 0"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
<div class="upload-progress bp3-progress-meter" style="width: 0"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`,
});
try {
@@ -88,6 +88,7 @@ async function handleClickUpload(files) {
Notification.success(i18n('File uploaded successfully.'));
await pjax.request({ push: false });
} catch (e) {
window.captureException?.(e);
console.error(e);
Notification.error(i18n('File upload failed: {0}', e.toString()));
} finally {
@@ -1,4 +1,6 @@
import { NamedPage } from 'vj/misc/Page';
import React from 'react';
import { createRoot } from 'react-dom/client';
import api, { gql } from 'vj/utils/api';
import loadReactRedux from 'vj/utils/loadReactRedux';
import parseQueryString from 'vj/utils/parseQueryString';
@@ -26,9 +28,7 @@ const page = new NamedPage('home_messages', () => {
const { default: WebSocket } = await import('../components/socket');
const { default: MessagePadApp } = await import('../components/messagepad');
const { default: MessagePadReducer } = await import('../components/messagepad/reducers');
const {
React, render, Provider, store,
} = await loadReactRedux(MessagePadReducer);
const { Provider, store } = await loadReactRedux(MessagePadReducer);
reduxStore = store;
@@ -71,7 +71,7 @@ const page = new NamedPage('home_messages', () => {
return this;
};
render(
createRoot($('#messagePad').get(0)).render(
<Provider store={store}>
<MessagePadApp
onAdd={async () => {
@@ -84,7 +84,6 @@ const page = new NamedPage('home_messages', () => {
}}
/>
</Provider>,
$('#messagePad').get(0),
);
}
+10 -10
View File
@@ -19,16 +19,16 @@ const page = new NamedPage('homework_main', () => {
calendar.getDom().appendTo('[name="calendar_entry"]');
$('[name="homework_display"]').change((ev) => {
switch (ev.currentTarget.value) {
case 'calendar':
$('.homework__list').hide();
$('[name="calendar_entry"]').show();
break;
case 'list':
$('.homework__list').show();
$('[name="calendar_entry"]').hide();
break;
default:
throw new Error('Unexpected display parameter');
case 'calendar':
$('.homework__list').hide();
$('[name="calendar_entry"]').show();
break;
case 'list':
$('.homework__list').show();
$('[name="calendar_entry"]').hide();
break;
default:
throw new Error('Unexpected display parameter');
}
});
}
@@ -0,0 +1,66 @@
.page--problem_config
.section--problem-sidebar-testdata
.problem-sidebar-testdata__detail
max-height: 270px
overflow-y: auto
.col--size
width: 80px
.col--operation
width: 40px
text-align: center
.textbox,.select
margin: 0
.bp4-tab-panel
margin-top: 5px
.problem-config-form
font-family: var(--code-font-family)
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button
-webkit-appearance: none !important
input[type="number"]
-moz-appearance: textfield
.bp4-tag.bp4-minimal
background: transparent
.bp4-input-action
pointer-events: none
.bp4-input-group
input
appearance: none
display: block
width: 100%
font-size: 0.875rem
height: 2.1875rem
line-height: 1.2
padding: 0.3125rem;
border: 1px solid #ddd
color: inherit
border-radius: 0
background-color: transparent
outline: 2px solid transparent
transition: outline-color 0.2s, border-color 0.2s
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1)
box-shadow: none
.data-table
tr
border-top: 0
border-bottom: 0
height: auto
td
padding: 0
.thead td
text-align: center
td input, td div
background: transparent
.hover-row.status-bar
display: none
input::placeholder
color: rgba(95,107,124,.6)!important
@@ -0,0 +1,203 @@
import { NamedPage } from 'vj/misc/Page';
import request from 'vj/utils/request';
import React from 'react';
import { createRoot } from 'react-dom/client';
import loadReactRedux from 'vj/utils/loadReactRedux';
import i18n from 'vj/utils/i18n';
import yaml from 'js-yaml';
import Notification from 'vj/components/notification';
import Dialog, { ConfirmDialog } from 'vj/components/dialog/index';
import download from 'vj/components/zipDownloader';
import { size, readSubtasksFromFiles, normalizeSubtasks } from '@hydrooj/utils/lib/common';
import tpl from 'vj/utils/tpl';
import { SubtaskType } from 'hydrooj/src/interface';
import { configYamlFormat } from 'vj/components/problemconfig/ProblemConfigEditor';
function onBeforeUnload(e) {
e.returnValue = '';
}
const page = new NamedPage('problem_config', () => {
let reduxStore;
async function handleClickUpload() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.click();
await new Promise((resolve) => { input.onchange = resolve; });
const { files } = input;
const dialog = new Dialog({
$body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`,
});
try {
Notification.info(i18n('Uploading files...'));
window.addEventListener('beforeunload', onBeforeUnload);
dialog.open();
const $uploadLabel = dialog.$dom.find('.dialog__body .upload-label');
const $uploadProgress = dialog.$dom.find('.dialog__body .upload-progress');
const $fileLabel = dialog.$dom.find('.dialog__body .file-label');
const $fileProgress = dialog.$dom.find('.dialog__body .file-progress');
for (const i in files) {
if (Number.isNaN(+i)) continue;
const file = files[i];
const data = new FormData();
data.append('filename', file.name);
data.append('file', file);
data.append('type', 'testdata');
data.append('operation', 'upload_file');
await request.postFile('./files', data, {
xhr() {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', () => {
$fileLabel.text(`[${+i + 1}/${files.length}] ${file.name}`);
$fileProgress.width(`${Math.round((+i + 1) / files.length * 100)}%`);
$uploadLabel.text(i18n('Uploading... ({0}%)', 0));
$uploadProgress.width(0);
});
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
if (percentComplete === 100) $uploadLabel.text(i18n('Processing...'));
else $uploadLabel.text(i18n('Uploading... ({0}%)', percentComplete));
$uploadProgress.width(`${percentComplete}%`);
}
}, false);
return xhr;
},
});
reduxStore.dispatch({
type: 'CONFIG_ADD_TESTDATA',
value: {
_id: file.name,
name: file.name,
size: file.size,
},
});
$('.testdata-table tbody').append(
$(tpl`<tr data-filename="${file.name}" data-size="${file.size.toString()}">
<td class="col--name" title="${file.name}"><a href="./file/${file.name}?type=testdata">${file.name}</a></td>
<td class="col--size">${size(file.size)}</td>
<td class="col--operation"><a href="javascript:;" name="testdata__delete"><span class="icon icon-delete"></span></a></td>
</tr>`),
);
}
window.removeEventListener('beforeunload', onBeforeUnload);
Notification.success(i18n('File uploaded successfully.'));
} catch (e) {
console.error(e);
Notification.error(i18n('File upload failed: {0}', e.toString()));
} finally {
dialog.close();
}
}
async function handleClickRemove(ev: JQuery.ClickEvent<Document, undefined, any, any>) {
const file = [$(ev.currentTarget).parent().parent().attr('data-filename')];
const action = await new ConfirmDialog({
$body: tpl.typoMsg(i18n('Confirm to delete the file?')),
}).open();
if (action !== 'yes') return;
try {
await request.post('./files', {
operation: 'delete_files',
files: file,
type: 'testdata',
});
Notification.success(i18n('File have been deleted.'));
reduxStore.dispatch({
type: 'CONFIG_DELETE_TESTDATA',
value: file,
});
$(ev.currentTarget).parent().parent().remove();
} catch (error) {
Notification.error(error.message);
}
}
async function handleClickDownloadAll() {
const files = reduxStore.getState().testdata.map((i) => i.name);
const { links, pdoc } = await request.post('./files', { operation: 'get_links', files, type: 'testdata' });
const targets = [];
for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] });
await download(`${pdoc.docId} ${pdoc.title}.zip`, targets);
}
async function uploadConfig(config: object) {
const configYaml = yaml.dump(configYamlFormat(config));
Notification.info(i18n('Saving file...'));
const data = new FormData();
data.append('filename', 'config.yaml');
data.append('file', new Blob([configYaml], { type: 'text/plain' }));
data.append('type', 'testdata');
data.append('operation', 'upload_file');
await request.postFile('./files', data);
Notification.success(i18n('File saved.'));
window.location.reload();
}
async function mountComponent() {
const [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([
import('vj/components/problemconfig/ProblemConfigEditor'),
import('vj/components/problemconfig/ProblemConfigForm'),
import('vj/components/problemconfig/reducer'),
]);
const { Provider, store } = await loadReactRedux(ProblemConfigReducer);
reduxStore = store;
store.dispatch({
type: 'CONFIG_LOAD',
payload: request.get(),
});
const unsubscribe = store.subscribe(() => {
// TODO set yaml schema
const state = store.getState();
if (!state.config.__loaded) return;
if (state.config.cases) {
const score = state.config.score * state.config.cases.length;
state.config.subtasks = [{ type: 'sum' as SubtaskType, score: score && score < 100 ? score : 100, cases: state.config.cases }];
delete state.config.cases;
delete state.config.score;
}
if (state.config.subtasks) return;
const testdata = (state.testdata || []).map((i) => i.name);
unsubscribe();
const subtasks = readSubtasksFromFiles(testdata, state.config);
store.dispatch({
type: 'CONFIG_AUTOCASES_UPDATE',
subtasks: normalizeSubtasks(subtasks, (i) => i, state.config.time, state.config.memory, true),
});
});
createRoot($('#ProblemConfig').get(0)).render(
<Provider store={store}>
<div className="row">
<div className="medium-5 columns">
<ProblemConfigEditor />
</div>
<div className="medium-7 columns">
<ProblemConfigForm />
</div>
</div>
<button className="rounded primary button" onClick={() => uploadConfig(store.getState().config)}>{i18n('Submit')}</button>
</Provider>,
);
}
mountComponent();
$(document).on('click', '[name="testdata__upload"]', () => handleClickUpload());
$(document).on('click', '[name="testdata__delete"]', (ev) => handleClickRemove(ev));
$(document).on('click', '[name="testdata__download__all"]', () => handleClickDownloadAll());
});
export default page;
@@ -1,4 +1,6 @@
import yaml from 'js-yaml';
import ReactDOM, { createRoot } from 'react-dom/client';
import React from 'react';
import { getScoreColor } from '@hydrooj/utils/lib/status';
import { NamedPage } from 'vj/misc/Page';
import { downloadProblemSet } from 'vj/components/zipDownloader';
@@ -132,9 +134,7 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor
const { default: WebSocket } = await import('../components/socket');
const { default: ScratchpadApp } = await import('../components/scratchpad');
const { default: ScratchpadReducer } = await import('../components/scratchpad/reducers');
const {
React, render, unmountComponentAtNode, Provider, store,
} = await loadReactRedux(ScratchpadReducer);
const { Provider, store } = await loadReactRedux(ScratchpadReducer);
const sock = new WebSocket(UiContext.pretestConnUrl);
sock.onmessage = (message) => {
@@ -145,17 +145,15 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor
});
};
const root = createRoot($('#scratchpad').get(0));
renderReact = () => {
render(
root.render(
<Provider store={store}>
<ScratchpadApp />
</Provider>,
$('#scratchpad').get(0),
);
};
unmountReact = () => {
unmountComponentAtNode($('#scratchpad').get(0));
};
unmountReact = () => root.unmount();
reactLoaded = true;
$('.loader-container').hide();
}
@@ -192,17 +190,12 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor
const props = yaml.load(s);
if (!(props instanceof Array)) return;
$('.outer-loader-container').show();
const [{ default: Objective }, React, ReactDOM] = await Promise.all([
import('vj/components/objective-question/index'),
import('react'),
import('react-dom'),
]);
const { default: Objective } = await import('vj/components/objective-question/index');
ReactDOM.render(
ReactDOM.createRoot($('.problem-content').get(0)).render(
<div className="section__body typo">
<Objective panel={props} target={UiContext.postSubmitUrl}></Objective>
</div>,
$('.problem-content').get(0),
);
$('.outer-loader-container').hide();
} catch (e) { }
+129 -1
View File
@@ -3,10 +3,13 @@ import { NamedPage } from 'vj/misc/Page';
import request from 'vj/utils/request';
import tpl from 'vj/utils/tpl';
import i18n from 'vj/utils/i18n';
import { ConfirmDialog } from 'vj/components/dialog';
import Dialog, { ConfirmDialog } from 'vj/components/dialog';
import Dropdown from 'vj/components/dropdown/Dropdown';
import Editor from 'vj/components/editor/index';
import Notification from 'vj/components/notification';
import { slideDown, slideUp } from 'vj/utils/slide';
import download from 'vj/components/zipDownloader';
import { size } from '@hydrooj/utils/lib/common';
const categories = {};
const dirtyCategories = [];
@@ -18,6 +21,10 @@ function setDomSelected($dom, selected) {
else $dom.removeClass('selected');
}
function onBeforeUnload(e) {
e.returnValue = '';
}
async function updateSelection() {
dirtyCategories.forEach(({ type, category, subcategory }) => {
let item = categories[category];
@@ -143,6 +150,21 @@ function buildCategoryFilter() {
});
}
async function handleSection(ev, sidebar, type) {
const $section = $(ev.currentTarget).closest(`.section--problem-sidebar-${sidebar}`);
if ($section.is(`.${type}d, .animating`)) return;
$section.addClass('animating');
const $detail = $section.find(`.section--problem-sidebar-${sidebar}__detail`);
if (type === 'expand') {
await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 });
} else {
await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 });
}
$section.addClass(type === 'expand' ? 'expanded' : 'collapsed');
$section.removeClass(type === 'expand' ? 'collapsed' : 'expanded');
$section.removeClass('animating');
}
export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
let confirmed = false;
$(document).on('click', '[name="operation"]', (ev) => {
@@ -170,6 +192,105 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
buildCategoryFilter();
parseCategorySelection();
async function handleClickUpload() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.click();
await new Promise((resolve) => { input.onchange = resolve; });
const { files } = input;
const dialog = new Dialog({
$body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`,
});
try {
Notification.info(i18n('Uploading files...'));
window.addEventListener('beforeunload', onBeforeUnload);
dialog.open();
const $uploadLabel = dialog.$dom.find('.dialog__body .upload-label');
const $uploadProgress = dialog.$dom.find('.dialog__body .upload-progress');
const $fileLabel = dialog.$dom.find('.dialog__body .file-label');
const $fileProgress = dialog.$dom.find('.dialog__body .file-progress');
for (const i in files) {
if (Number.isNaN(+i)) continue;
const file = files[i];
const data = new FormData();
data.append('filename', file.name);
data.append('file', file);
data.append('type', 'additional_file');
data.append('operation', 'upload_file');
await request.postFile('./files', data, {
xhr() {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', () => {
$fileLabel.text(`[${+i + 1}/${files.length}] ${file.name}`);
$fileProgress.width(`${Math.round((+i + 1) / files.length * 100)}%`);
$uploadLabel.text(i18n('Uploading... ({0}%)', 0));
$uploadProgress.width(0);
});
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
if (percentComplete === 100) $uploadLabel.text(i18n('Processing...'));
else $uploadLabel.text(i18n('Uploading... ({0}%)', percentComplete));
$uploadProgress.width(`${percentComplete}%`);
}
}, false);
return xhr;
},
});
$('.additionalfile-table tbody').append(
$(tpl`<tr data-filename="${file.name}" data-size="${file.size.toString()}">
<td class="col--name" title="${file.name}"><a href="./file/${file.name}?type=testdata">${file.name}</a></td>
<td class="col--size">${size(file.size)}</td>
<td class="col--operation"><a href="javascript:;" name="testdata__delete"><span class="icon icon-delete"></span></a></td>
</tr>`),
);
}
window.removeEventListener('beforeunload', onBeforeUnload);
Notification.success(i18n('File uploaded successfully.'));
} catch (e) {
console.error(e);
Notification.error(i18n('File upload failed: {0}', e.toString()));
} finally {
dialog.close();
}
}
async function handleClickRemove(ev) {
const file = [$(ev.currentTarget).parent().parent().attr('data-filename')];
const action = await new ConfirmDialog({
$body: tpl.typoMsg(i18n('Confirm to delete the file?')),
}).open();
if (action !== 'yes') return;
try {
await request.post('./files', {
operation: 'delete_files',
files: file,
type: 'testdata',
});
Notification.success(i18n('File have been deleted.'));
$(ev.currentTarget).parent().parent().remove();
} catch (error) {
Notification.error(error.message);
}
}
async function handleClickDownloadAll() {
const files = $('.additionalfile-table tr').map(function () { return $(this).attr('data-filename'); }).get();
const { links, pdoc } = await request.post('./files', { operation: 'get_links', files, type: 'additional_file' });
const targets = [];
for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] });
await download(`${pdoc.docId} ${pdoc.title}.zip`, targets);
}
setInterval(() => {
$('img').each(function () {
if (this.src.startsWith('file://')) {
@@ -232,4 +353,11 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
ev.preventDefault();
}
});
$(document).on('click', '[name="additional_file__upload"]', () => handleClickUpload());
$(document).on('click', '[name="additional_file__delete"]', (ev) => handleClickRemove(ev));
$(document).on('click', '[name="additional_file__download"]', () => handleClickDownloadAll());
$(document).on('click', '[name="additional_file__section__expand"]', (ev) => handleSection(ev, 'additional_file', 'expand'));
$(document).on('click', '[name="additional_file__section__collapse"]', (ev) => handleSection(ev, 'additional_file', 'collapse'));
$(document).on('click', '[name="tags__section__expand"]', (ev) => handleSection(ev, 'tags', 'expand'));
$(document).on('click', '[name="tags__section__collapse"]', (ev) => handleSection(ev, 'tags', 'collapse'));
});
@@ -1,4 +1,4 @@
.page--problem_edit
.page--problem_edit,.page--problem_create
.section__tab-header-item
border-top: 3px solid transparent;
transition: border-color 0.2s, background 0.2s, color 0.2s;
@@ -10,4 +10,14 @@
background: #f8f8f8;
&.tab--active
border-color: #ed5f82;
border-color: #ed5f82;
.section--problem-sidebar-additional_file
.problem-sidebar-additional_file__detail
height: 270px
overflow-y: auto
.col--size
width: 80px
.col--operation
width: 40px
@@ -51,12 +51,12 @@ const page = new NamedPage('problem_files', () => {
const dialog = new Dialog({
$body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
<div class="file-progress bp3-progress-meter" style="width: 0"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
<div class="upload-progress bp3-progress-meter" style="width: 0"></div>
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`,
});
try {
@@ -100,6 +100,7 @@ const page = new NamedPage('problem_files', () => {
Notification.success(i18n('File uploaded successfully.'));
await pjax.request({ push: false });
} catch (e) {
window.captureException?.(e);
console.error(e);
Notification.error(i18n('File upload failed: {0}', e.toString()));
} finally {
@@ -238,6 +239,7 @@ const page = new NamedPage('problem_files', () => {
const res = await request.get(link);
content = await request.get(res.url, undefined, { dataType: 'text' });
} catch (e) {
window.captureException?.(e);
Notification.error(i18n('Failed to load file: {0}', e.message));
throw e;
}
+9 -15
View File
@@ -7,7 +7,6 @@ import Dropdown from 'vj/components/dropdown/Dropdown';
import createHint from 'vj/components/hint';
import { downloadProblemSet } from 'vj/components/zipDownloader';
import pjax from 'vj/utils/pjax';
import substitute from 'vj/utils/substitute';
import request from 'vj/utils/request';
import tpl from 'vj/utils/tpl';
import delay from 'vj/utils/delay';
@@ -52,19 +51,14 @@ async function updateSelection(sendRequest = true) {
.map((s) => s.split(',')[0]));
// drop the category if its subcategory is selected
const requestTags = _.uniq(_.pullAll(selections, requestCategoryTags));
let url;
if (requestTags.length === 0) {
url = UiContext.getProblemUrlWithoutCategory;
} else {
url = substitute(decodeURIComponent(UiContext.getProblemUrlWithCategory), {
category: requestTags
.map((tag) => tag.split(',').map(encodeURIComponent).join(','))
.join(','), // build a beautiful URL
});
}
const q = $('[name="q"]').val();
if (q) url += `?q=${q}`;
pjax.request({ url });
let q = $('[name="q"]').val().split(' ').filter((i) => !i.startsWith('category:')).join(' ');
if (requestTags.length) q = requestTags.map((i) => `category:${i}`).join(' ') + (q ? ` ${q}` : '');
const url = new URL(window.location.href);
if (!q) url.searchParams.delete('q');
else url.searchParams.set('q', q);
url.searchParams.delete('page');
pjax.request({ url: url.toString() });
$('[name="q"]').val(q);
}
}
@@ -230,7 +224,7 @@ function processElement(ele) {
createHint('Hint::icon::difficulty', $(ele).find('th.col--difficulty'));
}
const page = new NamedPage(['problem_main', 'problem_category'], () => {
const page = new NamedPage(['problem_main'], () => {
const doc = document.body;
doc.className += ' display-mode';
$('.section.display-mode').removeClass('display-mode');
-54
View File
@@ -1,54 +0,0 @@
import ReconnectingWebsocket from 'reconnecting-websocket';
console.log('SharedWorker init');
const ports: MessagePort[] = [];
let conn: ReconnectingWebsocket;
function initSharedConn(path: string, sid: string) {
if (conn) return;
console.log('Init connection for', path);
const url = new URL(path);
url.protocol = url.protocol.replace('http', 'ws');
url.searchParams.append('sid', sid);
const sock = new ReconnectingWebsocket(url.toString());
conn = sock;
sock.onopen = () => console.log('Connected');
sock.onerror = console.error;
sock.onclose = (...args) => console.log('Closed', ...args);
sock.onmessage = async (message) => {
if (process.env.NODE_ENV !== 'production') console.log('SharedWorker.port.onmessage: ', message);
const payload = JSON.parse(message.data);
if (payload.event === 'auth') {
if (['PermissionError', 'PrivilegeError'].includes(payload.error)) {
conn?.close();
conn = null;
}
return;
}
for (const port of ports) {
port.postMessage({ type: 'message', payload: message.data });
const success = await new Promise((resolve) => {
const handle = (msg) => {
if (['success', 'fail'].includes(msg.data.type)) {
port.removeEventListener('message', handle);
resolve(msg.data.type === 'success');
}
};
port.addEventListener('message', handle);
});
if (success) return;
}
console.log('Failed to push notification');
// TODO try system notification
};
}
// @ts-ignore
onconnect = function (e) { // eslint-disable-line no-undef
const port: MessagePort = e.ports[0];
port.addEventListener('message', (msg) => {
initSharedConn(msg.data.path, msg.data.cookie);
});
port.start();
ports.push(port);
};
@@ -21,7 +21,7 @@
{%- if pdoc.tag|length > 0 %}
{%- if not inline %}<ul class="problem__tags">{% endif -%}
{%- for tag in pdoc['tag'] %}
<li class="problem__tag"><a class="problem__tag-link" href="{{ url('problem_category', category=tag) }}">{{ tag }}</a></li>
<li class="problem__tag"><a class="problem__tag-link" href="{{ url('problem_main', query={q:'category:'+tag}) }}">{{ tag }}</a></li>
{%- endfor -%}
{%- if not inline %}</ul>{% endif -%}
{%- else -%}
@@ -11,14 +11,14 @@
<h1 class="section__title">{{ tdoc.title }}</h1>
</div>
<div class="section__body">
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _(model.contest.statusText(tdoc)) }}</span>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-award">{{ _(model.contest.RULES[tdoc.rule].TEXT) }}</span>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }}</span>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-schedule">{{ contest.render_duration(tdoc) }} {{ _('hour(s)') }}</span>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }}</span>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-user--multiple">{{ tdoc['attend']|default(0) }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _(model.contest.statusText(tdoc)) }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-award">{{ _(model.contest.RULES[tdoc.rule].TEXT) }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-schedule">{{ contest.render_duration(tdoc) }} {{ _('hour(s)') }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-user--multiple">{{ tdoc['attend']|default(0) }}</span>
{% if tsdoc.attend %}
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-check">{{ _('Attended') }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-check">{{ _('Attended') }}</span>
{% endif %}
</div>
</div>
@@ -7,10 +7,10 @@
</div>
<div class="section__body typo">
<p>{{ _('User can join this domain by visiting the following URL') }}:</p>
<pre><code class="language-data">{{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}</code></pre>
<pre><code class="language-data">{{ url_prefix|safe }}d/{{ handler.args.domainId }}/domain/join</code></pre>
{% if joinSettings['method'] == model.domain.JOIN_METHOD_CODE %}
<p>{{ _('Or, with automatically filled invitation code') }}:</p>
<pre><code class="language-data">{{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}&code={{ joinSettings.code|urlencode }}</code></pre>
<pre><code class="language-data">{{ url_prefix|safe }}d/{{ handler.args.domainId }}/domain/join?code={{ joinSettings.code|urlencode }}</code></pre>
{% endif %}
{% if joinSettings.expire %}
<br/>
@@ -2,13 +2,13 @@
{%- for category, sub_categories in model.system.get('problem.categories')|parseYaml -%}
<li class="group-list__item">
<h2 class="section__title">
<a href="{{ url('problem_category', category=category) }}" data-category="{{ category }}">{{ category }}</a>
<a href="{{ url('problem_main', query={q:'category:'+category}) }}" data-category="{{ category }}">{{ category }}</a>
</h2>
{% if sub_categories | length > 0 %}
<ol class="chip-list">
{%- for sub_category in sub_categories -%}
<li class="chip-list__item">
<a class="typo-a" href="{{ url('problem_category', category=(category + ',' + sub_category)) }}">{{ sub_category }}</a>
<a class="typo-a" href="{{ url('problem_main', query={q:'category:'+sub_category}) }}">{{ sub_category }}</a>
</li>
{%- endfor -%}
</ol>
@@ -11,24 +11,6 @@
<dd>{{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB</dd>
<dt>{{ _('Difficulty') }}</dt>
<dd>{{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</dd>
<dt>{{ _('Category') }}</dt>
<dd>
{% if pdoc['category'] %}
<span class="hasjs--hide" name="problem-sidebar__categories">
{%- for category in pdoc['category'] -%}
<a href="{{ url('problem_category', category=category) }}">{{ category }}</a>
{% if loop.index < pdoc['category']|length %}
{{ _('problem-subcategory-delim') }}
{% endif %}
{%- endfor -%}
</span>
<span class="nojs--hide">
<a href="javascript:;" name="problem-sidebar__show-category">{{ _('page.problem_detail.sidebar.show_category') }}</a>
</span>
{% else %}
{{ _('(None)') }}
{% endif %}
</dd>
<dt>{{ _('Tags') }}</dt>
<dd>
{{ problem.render_problem_tags(pdoc, true) }}
@@ -6,7 +6,7 @@
{% if not pdocs.length %}
{{ nothing.render('Sorry, there is no problem in the problem set') }}
{% else %}
{{ paginator.render(page, ppcount, position='top', add_qs=qs) }}
{{ paginator.render(page, ppcount, position='top', add_qs=('q=' + qs if qs else '')) }}
<table class="data-table hide-problem-tag">
<colgroup>
<col class="col--checkbox display-mode-hide">
@@ -73,6 +73,6 @@
{%- endfor -%}
</tbody>
</table>
{{ paginator.render(page, ppcount, add_qs=qs) }}
{{ paginator.render(page, ppcount, add_qs=('q=' + qs if qs else '')) }}
{% endif %}
</div>
@@ -1,6 +1,6 @@
<div data-fragment-id="problem_lucky">
<p>
<a href="{{ url('problem_random', query={category:category}) if category else url('problem_random') }}" class="expanded button" target="_blank">
<a href="{{ url('problem_random', query={q:qs}) if qs else url('problem_random') }}" class="expanded button" target="_blank">
<span class="icon icon-feeling-lucky"></span> {{ _('Lucky') }}
</a>
</p>
@@ -0,0 +1,48 @@
{% import "components/nothing.html" as nothing with context %}
{% import "components/problem.html" as problem with context %}
<div class="section side section--problem-sidebar-additional_file">
<div class="section__header">
<h1 class="section__title">{{ additional_file.length|default(0) }} {{_('Additional File')}}</h1>
</div>
<ol class="menu">
<li class="menu__item flex-row">
<a class="menu__link half" href="javascript:;" name="additional_file__upload">
<span class="icon icon-upload"></span> {{ _('Upload') }}
</a>
<a class="menu__link half" href="javascript:;" name="additional_file__download">
<span class="icon icon-download"></span> {{ _('Download All') }}
</a>
</li>
</ol>
<div class="section__body no-padding problem-sidebar-additional_file__detail" data-fragment-id="problem-sidebar-additional_file">
{% if additional_file.length %}
<table class="data-table additionalfile-table">
<colgroup>
<col class="col--name">
<col class="col--size">
<col class="col--operation">
</colgroup>
<thead>
<tr>
<th class="col--name">{{ _('Filename') }}</th>
<th class="col--size">{{ _('Size') }}</th>
<th class="col--operation"><span class="icon icon-wrench"></span></th>
</tr>
</thead>
<tbody>
{%- for file in additional_file -%}
<tr data-filename="{{ file.name }}" data-size="{{ file.size }}">
<td class="col--name" title="{{ file.name }}">
<a href="{{ url('problem_file_download', {pid: pdoc.docId, filename: file.name}) }}">{{ file.name }}</a>
</td>
<td class="col--size">{{ size(file.size) }}</td>
<td class="col--operation"><a href="javascript:;"name="additional_file__delete"><span class="icon icon-delete"></span></a></td>
</tr>
{%- endfor -%}
</tbody>
</table>
{% else %}
{{ nothing.render('No additional file at current.',compact = true) }}
{% endif %}
</div>
</div>
@@ -83,6 +83,9 @@
<li class="menu__item"><a class="menu__link{% if page_name == 'problem_edit' %} active{% endif %}" href="{{ url('problem_edit', pid=pdoc.pid|default(pdoc.docId)) }}">
<span class="icon icon-edit"></span> {{ _('Edit') }}
</a></li>
<li class="menu__item"><a class="menu__link{% if page_name == 'problem_config' %} active{% endif %}" href="{{ url('problem_config', pid=pdoc.pid|default(pdoc.docId)) }}">
<span class="icon icon-settings"></span> {{ _('Judge Config') }}
</a></li>
{% endif %}
{% if page_name == 'problem_detail' %}
<li class="menu__seperator nojs--hide"></li>
@@ -100,14 +103,14 @@
</ol>
</div>
</div>
{% if page_name != 'problem_detail' and page_name != 'homework_detail_problem' and page_name != 'contest_detail_problem' %}
{% if page_name not in ['problem_config', 'problem_detail', 'homework_detail_problem', 'contest_detail_problem'] %}
<div class="section side">
<div class="section__header">
<h1 class="section__title">{{ _('Information') }}</h1>
</div>
{% include "partials/problem-sidebar-information.html" %}
</div>
{% elif page_name != 'contest_detail_problem' %}
{% elif page_name != 'contest_detail_problem' and page_name != 'problem_config' %}
<div class="section side nojs--hide">
<div class="section__header">
<h1 class="section__title">{{ _('Statistics') }}</h1>
@@ -0,0 +1,47 @@
{% import "components/nothing.html" as nothing with context %}
{% import "components/problem.html" as problem with context %}
<div class="section side section--problem-sidebar-testdata">
<div class="section__header">
<h1 class="section__title">{{ testdata.length if testdata.length else 0 }} {{_('Testdata')}}</h1>
</div>
<ol class="menu">
<li class="menu__item flex-row">
<a class="menu__link half" href="javascript:;" name="testdata__upload">
<span class="icon icon-upload"></span> {{ _('Upload') }}
</a>
<a class="menu__link half" href="javascript:;" name="testdata__download">
<span class="icon icon-download"></span> {{ _('Download All') }}
</a>
</li>
<li class="menu__seperator"></li>
</ol>
<div class="section__body no-padding problem-sidebar-testdata__detail" data-fragment-id="problem-sidebar-testdata">
{% if testdata.length %}
<table class="data-table testdata-table">
<colgroup>
<col class="col--name">
<col class="col--size">
<col class="col--operation">
</colgroup>
<thead>
<tr>
<th class="col--name">{{ _('Filename') }}</th>
<th class="col--size">{{ _('Size') }}</th>
<th class="col--operation"><span class="icon icon-wrench"></span></th>
</tr>
</thead>
<tbody>
{%- for file in testdata -%}
<tr data-filename="{{ file.name }}" data-size="{{ file.size }}">
<td class="col--name" title="{{ file.name }}"><a href="{{ url('problem_file_download', {pid: pdoc.docId, filename: file.name, query:{type:'testdata'} }) }}">{{ file.name }}</a></td>
<td class="col--size">{{ size(file.size) }}</td>
<td class="col--operation"><a href="javascript:;" name="testdata__delete"><span class="icon icon-delete"></span></a></td>
</tr>
{%- endfor -%}
</tbody>
</table>
{% else %}
{{ nothing.render('No testdata at current.', compact = true) }}
{% endif %}
</div>
</div>
@@ -0,0 +1,21 @@
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__header">
<h1 class="section__title">{{_('Judge Config')}}(Beta)</h1>
</div>
<div id="ProblemConfig" class="section__body">
{{ noscript_note.render() }}
<div class="loader-container"><div class="loader"></div></div>
</div>
</div>
</div>
<div class="medium-3 columns">
{% include "partials/problem_sidebar_testdata.html" %}
{% set owner_udoc = udoc %}
{% include "partials/problem_sidebar.html" %}
</div>
</div>
{% endblock %}
@@ -49,22 +49,22 @@
</div>
<div class="section__body">
<ul class="problem__tags">
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('ID') }}: {{ pdoc.docId }}</span>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-book">{{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('ID') }}: {{ pdoc.docId }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-book">{{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }}</span>
{% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %}
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-stopwatch">{{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms</span>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-comparison">{{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-stopwatch">{{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-comparison">{{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB</span>
{% endif %}
{% if not tdoc %}
<a class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId}) }}">{{ _('Tried') }}: {{ pdoc.nSubmit }}</a>
<a class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId, status:1}) }}">{{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }}</a>
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</span>
<a class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId}) }}">{{ _('Tried') }}: {{ pdoc.nSubmit }}</a>
<a class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId, status:1}) }}">{{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }}</a>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</span>
{% endif %}
<span class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item">{{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }}</span>
<span class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item">{{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }}</span>
{% if pdoc.tag.length %}
<span name="show_tags" class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item nojs--hide icon icon-tag">{{ _('Tags') + '>' }}</span>
<span name="show_tags" class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item nojs--hide icon icon-tag">{{ _('Tags') + '>' }}</span>
{%- for tag in pdoc.tag -%}
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item tags hasjs--hide">{{ tag }}</span>
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item tags hasjs--hide">{{ tag }}</span>
{%- endfor -%}
{% endif %}
</ul>
@@ -92,14 +92,15 @@
</div>
</div>
<div class="medium-3 columns">
<div class="section side visiable nojs--hide">
<div class="section side visiable nojs--hide section--problem-sidebar-tags">
<div class="section__header">
<h1 class="section__title">{{ _('Categories') }} ({{ _('click to add') }})</h1>
</div>
<div class="section__body">
<div class="section__body problem-sidebar-tags__detail">
{% include "partials/category.html" %}
</div>
</div>
{% include "partials/problem_sidebar_additional_file.html" %}
{% if page_name == 'problem_edit' %}
{% set owner_udoc = udoc %}
{% include "partials/problem_sidebar.html" %}
@@ -7,8 +7,9 @@
<h1 class="section__title">{{ _('Testdata') }}</h1>
<div class="section__tools">
{% if not reference and (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) %}
<button class="primary rounded button" name="create_testdata">{{ _('Create File') }}</button>
<button class="primary rounded button" name="upload_testdata">{{ _('Upload File') }}</button>
<a href="{{ url('problem_config', pid=pdoc.docId) }}" class="primary rounded button">{{ _('Config') }}</a>
<button class="primary rounded button" name="create_testdata">{{ _('Create') }}</button>
<button class="primary rounded button" name="upload_testdata">{{ _('Upload') }}</button>
{% endif %}
</div>
</div>
@@ -3,8 +3,6 @@
{% extends "layout/basic.html" %}
{% block content %}
{{ set(UiContext, {
getProblemUrlWithCategory: url('problem_category', category='{category}'),
getProblemUrlWithoutCategory: url('problem_main'),
currentCategory: category
}) }}
<div class="row">
@@ -13,7 +11,7 @@
<div class="section__body no-padding">
<form method="get" id="searchForm" action="{{ url('problem_main') }}">
<div class="search">
<input name="q" type="text" value="{{ category }}" placeholder="{{ _('Search') }}">
<input name="q" type="text" value="{{ qs }}" placeholder="{{ _('Search') }}">
{% include "partials/problem_stat.html" %}
<button type="submit">
<span class="icon icon-search"></span>
+1 -1
View File
@@ -212,7 +212,7 @@ $hover-background-color = #424242
.menu__link.highlight
background: rgb(40, 11, 20)
.bp3-tag.bp3-minimal:not([class*="bp3-intent-"])
.bp4-tag.bp4-minimal:not([class*="bp4-intent-"])
background-color: rgba(87, 100, 117, .2)
color: #d9dfe7
+2 -2
View File
@@ -4,8 +4,8 @@ export default function getAvailableLangs(langsList?: string[]) {
const Langs = {};
for (const key in LANGS) {
if (prefixes.has(key)) continue;
if (langsList && !langsList.includes(key)) continue;
if (LANGS[key].hidden && !langsList?.includes(key)) continue;
if (langsList && langsList.length && langsList.join('') && !langsList.includes(key)) continue;
else if (LANGS[key].hidden && !langsList?.includes(key)) continue;
Langs[key] = LANGS[key];
}
return Langs;
+1
View File
@@ -1,6 +1,7 @@
import substitute from './substitute';
export default function i18n(str, ...params) {
if (!str) return '';
return substitute(window.LOCALES?.[str] || str, params);
}
+2 -3
View File
@@ -2,7 +2,7 @@ import { Action, AnyAction, Reducer } from 'redux';
export default async function loadReactRedux<S, A extends Action = AnyAction>(storeReducer: Reducer<S, A>) {
const React = await import('react');
const { render, unmountComponentAtNode } = await import('react-dom');
const { createRoot } = await import('react-dom/client');
const { Provider } = await import('react-redux');
const { createStore, applyMiddleware } = await import('redux');
const { default: reduxThunk } = await import('redux-thunk');
@@ -24,8 +24,7 @@ export default async function loadReactRedux<S, A extends Action = AnyAction>(st
return {
React,
render,
unmountComponentAtNode,
createRoot,
Provider,
store,
};
-8
View File
@@ -1,8 +0,0 @@
// eslint-disable-next-line import/no-unresolved
import yaml, { Schema } from 'real-js-yaml';
Schema.create = (arg1, arg2) => new Schema(arg1, arg2);
// eslint-disable-next-line import/no-unresolved
export * from 'real-js-yaml';
export default yaml;