mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
add ui
This commit is contained in:
1 parent
777a4bc927
commit
9c45527522
396 files changed
+13772
No files matched your search
@@ -0,0 +1,17 @@
|
||||
.scratchpad__data-input
|
||||
border: 0
|
||||
border-left: 3px solid #E0E0E0
|
||||
background: #F8F8F8
|
||||
padding: rem(8px 4px)
|
||||
font-family: $code-font-family
|
||||
font-size: rem($font-size-secondary)
|
||||
outline: 0
|
||||
resize: none
|
||||
color: #666
|
||||
height: 100%
|
||||
width: 100%
|
||||
transition: background-color .2s linear, border-color .2s linear
|
||||
|
||||
&:focus
|
||||
background: #FFF
|
||||
border-color: lighten($primary-color, 70%)
|
||||
@@ -0,0 +1,34 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function DataInputComponent(props) {
|
||||
const {
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
const cn = classNames(className, 'flex-col flex-fill');
|
||||
return (
|
||||
<div {...rest} className={cn}>
|
||||
<textarea
|
||||
className="scratchpad__data-input"
|
||||
wrap="off"
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onChange(ev.target.value);
|
||||
}}
|
||||
placeholder={title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DataInputComponent.propTypes = {
|
||||
title: PropTypes.node,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
.ReactCodeMirror
|
||||
flex: 1
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
.cm-s-vjcm
|
||||
position: absolute
|
||||
left: 0
|
||||
top: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
font-family: $code-font-family
|
||||
font-size: rem($font-size-secondary)
|
||||
line-height: 1.2
|
||||
|
||||
&.CodeMirror-focused .CodeMirror-selected
|
||||
background: #cee4f2
|
||||
|
||||
.CodeMirror-gutters
|
||||
border-right: 0
|
||||
background: none
|
||||
|
||||
.CodeMirror-linenumber
|
||||
color: #DDD
|
||||
@@ -0,0 +1,8 @@
|
||||
@import './var.inc.styl'
|
||||
|
||||
.scratchpad__panel-title
|
||||
background: $primary-color
|
||||
line-height: rem($panel-header-height)
|
||||
padding: rem(0 5px)
|
||||
font-size: rem($font-size-small)
|
||||
color: #FFF
|
||||
@@ -0,0 +1,15 @@
|
||||
@import './var.inc.styl'
|
||||
|
||||
.scratchpad__panel-button
|
||||
background: none
|
||||
border: 0
|
||||
line-height: rem($panel-header-height)
|
||||
margin: 0
|
||||
color: #FFF
|
||||
padding: rem(0 5px)
|
||||
cursor: pointer
|
||||
font-size: rem($font-size-small)
|
||||
outline: 0
|
||||
|
||||
&:hover
|
||||
color: $tab-color
|
||||
@@ -0,0 +1,19 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function PanelButtonComponent(props) {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const cn = classNames(className, 'scratchpad__panel-button');
|
||||
return (
|
||||
<button {...rest} className={cn}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
PanelButtonComponent.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import SplitPaneFillOverlay from 'vj/components/react-splitpane/SplitPaneFillOverlayComponent';
|
||||
|
||||
export default function PanelComponent(props) {
|
||||
const {
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const cn = classNames(className, 'flex-col');
|
||||
return (
|
||||
<SplitPaneFillOverlay {...rest} className={cn}>
|
||||
<div className="scratchpad__panel-title">{title}</div>
|
||||
<div className="flex-col flex-fill">{children}</div>
|
||||
</SplitPaneFillOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
PanelComponent.propTypes = {
|
||||
title: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
@import './var.inc.styl'
|
||||
|
||||
.scratchpad__panel-tab
|
||||
margin-top: rem(-($panel-header-height))
|
||||
|
||||
&.rc-tabs-top
|
||||
border: 0
|
||||
|
||||
&.rc-tabs-top .rc-tabs-bar
|
||||
border: 0
|
||||
|
||||
.rc-tabs-bar
|
||||
padding-left: rem(120px)
|
||||
|
||||
.rc-tabs-ink-bar
|
||||
background: $tab-color
|
||||
|
||||
.rc-tabs-tab-prev-icon, .rc-tabs-tab-next-icon
|
||||
display: none
|
||||
font-family: $icon-font-name !important
|
||||
line-height: rem($panel-header-height)
|
||||
font-size: rem($font-size)
|
||||
color: #FFF
|
||||
|
||||
.rc-tabs-tab-arrow-show
|
||||
display: block
|
||||
|
||||
&.rc-tabs-top .rc-tabs-tab-prev-icon:before
|
||||
content: $icon-chevron_left
|
||||
|
||||
&.rc-tabs-top .rc-tabs-tab-next-icon:before
|
||||
content: $icon-chevron_right
|
||||
|
||||
.rc-tabs-content
|
||||
flex: 1
|
||||
|
||||
.rc-tabs-tabpane
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
&.rc-tabs-top .rc-tabs-tab
|
||||
padding: rem(0 5px)
|
||||
line-height: rem($panel-header-height)
|
||||
margin: 0
|
||||
margin-right: rem(5px)
|
||||
color: rgba(#FFF, 0.8)
|
||||
|
||||
&.rc-tabs-top .rc-tabs-tab-active
|
||||
&, &:hover
|
||||
color: #FFF
|
||||
|
||||
.rc-tabs-bar, .rc-tabs-nav-container
|
||||
line-height: 1
|
||||
font-size: rem($font-size-small)
|
||||
|
||||
.rc-tabs-tab-btn-disabled
|
||||
color: rgba(#FFF, 0.7)
|
||||
|
||||
.rc-tabs-tabpane-hidden
|
||||
display: none
|
||||
|
||||
.rc-tabs-tabpane
|
||||
overflow: auto
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import CodeMirror from 'react-codemirror';
|
||||
|
||||
import 'codemirror/addon/edit/matchbrackets';
|
||||
import 'codemirror/mode/clike/clike';
|
||||
import 'codemirror/mode/pascal/pascal';
|
||||
import 'codemirror/mode/python/python';
|
||||
import 'codemirror/mode/php/php';
|
||||
import 'codemirror/mode/rust/rust';
|
||||
import 'codemirror/mode/haskell/haskell';
|
||||
import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/mode/go/go';
|
||||
import 'codemirror/mode/ruby/ruby';
|
||||
|
||||
import * as languageEnum from 'vj/constant/language';
|
||||
|
||||
const getOptions = lang => ({
|
||||
lineNumbers: true,
|
||||
tabSize: 4,
|
||||
indentUnit: 4,
|
||||
indentWithTabs: true,
|
||||
mode: languageEnum.LANG_CODEMIRROR_MODES[lang],
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
code: state.editor.code,
|
||||
lang: state.editor.lang,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
handleUpdateCode: (code) => {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_EDITOR_UPDATE_CODE',
|
||||
payload: code,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class ScratchpadEditorContainer extends React.PureComponent {
|
||||
componentDidMount() {
|
||||
this.refs.editor.getCodeMirror().setOption('theme', 'vjcm');
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CodeMirror
|
||||
value={this.props.code}
|
||||
onChange={code => this.props.handleUpdateCode(code)}
|
||||
options={getOptions(this.props.lang)}
|
||||
ref="editor"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Tabs, { TabPane } from 'rc-tabs';
|
||||
import TabContent from 'rc-tabs/lib/TabContent';
|
||||
import ScrollableInkTabBar from 'rc-tabs/lib/ScrollableInkTabBar';
|
||||
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import Icon from 'vj/components/react/IconComponent';
|
||||
import Panel from './PanelComponent';
|
||||
import PanelButton from './PanelButtonComponent';
|
||||
import ScratchpadPretestTabPane from './ScratchpadPretestTabPaneContainer';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
current: state.pretest.current,
|
||||
tabs: state.pretest.tabs,
|
||||
meta: state.pretest.meta,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
handleClickAdd() {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_PRETEST_ADD_DATA',
|
||||
});
|
||||
},
|
||||
handleClickRemove() {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_PRETEST_REMOVE_DATA',
|
||||
});
|
||||
},
|
||||
handleSwitchData(id) {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_PRETEST_SWITCH_TO_DATA',
|
||||
payload: id,
|
||||
});
|
||||
},
|
||||
handleClickClose() {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_UI_SET_VISIBILITY',
|
||||
payload: {
|
||||
uiElement: 'pretest',
|
||||
visibility: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class ScratchpadPretestContainer extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<Panel
|
||||
title={(
|
||||
<span>
|
||||
<Icon name="edit" />
|
||||
{' '}
|
||||
{i18n('Pretest')}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
className="scratchpad__panel-tab flex-col flex-fill"
|
||||
activeKey={this.props.current}
|
||||
onChange={tabId => this.props.handleSwitchData(tabId)}
|
||||
animation="slide-horizontal"
|
||||
renderTabBar={() => (
|
||||
<ScrollableInkTabBar
|
||||
extraContent={(
|
||||
<span>
|
||||
<PanelButton
|
||||
data-tooltip={i18n('Add new data')}
|
||||
onClick={() => this.props.handleClickAdd()}
|
||||
>
|
||||
{i18n('Add')}
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
data-tooltip={i18n('Remove this data')}
|
||||
onClick={() => this.props.handleClickRemove()}
|
||||
>
|
||||
{i18n('Remove')}
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={() => this.props.handleClickClose()}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</PanelButton>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderTabContent={() => <TabContent />}
|
||||
>
|
||||
{this.props.tabs.map(tabId => (
|
||||
<TabPane tab={this.props.meta[tabId].title} key={tabId}>
|
||||
<ScratchpadPretestTabPane id={tabId} />
|
||||
</TabPane>
|
||||
))}
|
||||
</Tabs>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import DataInput from './DataInputComponent';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
data: state.pretest.data,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
handleDataChange(id, type, value) {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_PRETEST_DATA_CHANGE',
|
||||
payload: {
|
||||
id,
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mergeProps = (stateProps, dispatchProps, ownProps) => ({
|
||||
...dispatchProps,
|
||||
id: ownProps.id,
|
||||
input: stateProps.data[ownProps.id] ? stateProps.data[ownProps.id].input : '',
|
||||
output: stateProps.data[ownProps.id] ? stateProps.data[ownProps.id].output : '',
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps, mergeProps)
|
||||
export default class ScratchpadPretestTabPaneContainer extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="flex-row flex-fill">
|
||||
<DataInput
|
||||
title={i18n('Sample Input')}
|
||||
value={this.props.input}
|
||||
onChange={v => this.props.handleDataChange(this.props.id, 'input', v)}
|
||||
/>
|
||||
<DataInput
|
||||
title={i18n('Sample Output')}
|
||||
value={this.props.output}
|
||||
onChange={v => this.props.handleDataChange(this.props.id, 'output', v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Tabs, { TabPane } from 'rc-tabs';
|
||||
import TabContent from 'rc-tabs/lib/TabContent';
|
||||
import ScrollableInkTabBar from 'rc-tabs/lib/ScrollableInkTabBar';
|
||||
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import request from 'vj/utils/request';
|
||||
import Icon from 'vj/components/react/IconComponent';
|
||||
import Panel from './PanelComponent';
|
||||
import PanelButton from './PanelButtonComponent';
|
||||
import ScratchpadRecordsTable from './ScratchpadRecordsTableContainer';
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
loadSubmissions() {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS',
|
||||
payload: request.get(Context.getSubmissionsUrl),
|
||||
});
|
||||
},
|
||||
handleClickClose() {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_UI_SET_VISIBILITY',
|
||||
payload: {
|
||||
uiElement: 'records',
|
||||
visibility: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
handleClickRefresh() {
|
||||
this.loadSubmissions();
|
||||
},
|
||||
});
|
||||
|
||||
@connect(null, mapDispatchToProps)
|
||||
export default class ScratchpadRecordsContainer extends React.PureComponent {
|
||||
componentDidMount() {
|
||||
this.props.loadSubmissions();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Panel
|
||||
title={(
|
||||
<span>
|
||||
<Icon name="flag" />
|
||||
{' '}
|
||||
{i18n('Records')}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
className="scratchpad__panel-tab flex-col flex-fill"
|
||||
activeKey="all"
|
||||
animation="slide-horizontal"
|
||||
renderTabBar={() => (
|
||||
<ScrollableInkTabBar
|
||||
extraContent={(
|
||||
<span>
|
||||
<PanelButton
|
||||
data-tooltip={i18n('Refresh Records')}
|
||||
data-tooltip-pos="top right"
|
||||
onClick={() => this.props.handleClickRefresh()}
|
||||
>
|
||||
{i18n('Refresh')}
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={() => this.props.handleClickClose()}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</PanelButton>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderTabContent={() => <TabContent />}
|
||||
>
|
||||
<TabPane tab={<span>{i18n('All')}</span>} key="all">
|
||||
<ScratchpadRecordsTable />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import TimeAgo from 'timeago-react';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
|
||||
import substitute from 'vj/utils/substitute';
|
||||
import emulateAnchorClick from 'vj/utils/emulateAnchorClick';
|
||||
import { parse as parseMongoId } from 'vj/utils/mongoId';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import * as recordEnum from 'vj/constant/record';
|
||||
|
||||
const shouldShowDetail = data => recordEnum.STATUS_SCRATCHPAD_SHOW_DETAIL_FLAGS[data.status];
|
||||
|
||||
const isPretest = data => data.type === recordEnum.TYPE_PRETEST;
|
||||
|
||||
const getRecordDetail = (data) => {
|
||||
if (!shouldShowDetail(data)) {
|
||||
return (
|
||||
<span className={`record-status--text ${recordEnum.STATUS_CODES[data.status]}`}>
|
||||
{recordEnum.STATUS_TEXTS[data.status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const stat = _.pick(
|
||||
_.groupBy(data.cases || [], 'status'),
|
||||
_.keys(recordEnum.STATUS_SCRATCHPAD_SHORT_TEXTS),
|
||||
);
|
||||
return _.map(recordEnum.STATUS_SCRATCHPAD_SHORT_TEXTS, (text, status) => {
|
||||
const count = (stat[status] && stat[status].length) || 0;
|
||||
const cn = classNames('icol icol--stat', {
|
||||
'record-status--text': count > 0,
|
||||
[recordEnum.STATUS_CODES[data.status]]: count > 0,
|
||||
});
|
||||
return (
|
||||
<span key={text} className={cn}>
|
||||
{text}: {count}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
items: state.records.items,
|
||||
});
|
||||
|
||||
const mergeProps = (stateProps, dispatchProps, ownProps) => ({
|
||||
...dispatchProps,
|
||||
data: stateProps.items[ownProps.id],
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, null, mergeProps)
|
||||
export default class ScratchpadRecordsRowContainer extends React.PureComponent {
|
||||
handleRowClick(ev, id) {
|
||||
const url = substitute(
|
||||
decodeURIComponent(Context.getRecordDetailUrl),
|
||||
{ rid: id }
|
||||
);
|
||||
emulateAnchorClick(ev, url, true);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data } = this.props;
|
||||
const submitAt = parseMongoId(data._id).timestamp * 1000;
|
||||
return (
|
||||
<tr onClick={ev => this.handleRowClick(ev, data._id)}>
|
||||
<td className={`col--detail record-status--border ${recordEnum.STATUS_CODES[data.status]}`}>
|
||||
<span className={`icon record-status--icon ${recordEnum.STATUS_CODES[data.status]}`}></span>
|
||||
<span className="icol icol--pretest">
|
||||
{isPretest(data)
|
||||
? <span className={`flag record-status--background ${recordEnum.STATUS_CODES[data.status]}`}>{i18n('Pretest')}</span>
|
||||
: ''
|
||||
}
|
||||
</span>
|
||||
{getRecordDetail(data)}
|
||||
</td>
|
||||
<td className="col--memory">
|
||||
{shouldShowDetail(data) ? `${Math.ceil(data.memory_kb / 1000)} MB` : '-'}
|
||||
</td>
|
||||
<td className="col--time">
|
||||
{shouldShowDetail(data) ? `${(data.time_ms / 1000).toFixed(1)}s` : '-'}
|
||||
</td>
|
||||
<td className="col--at">
|
||||
<time data-tooltip={moment(submitAt).format('YYYY-MM-DD HH:mm:ss')}>
|
||||
<TimeAgo datetime={submitAt} locale={i18n('timeago_locale')} />
|
||||
</time>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import ScratchpadRecordsRow from './ScratchpadRecordsRowContainer';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
rows: state.records.rows,
|
||||
isLoading: state.ui.records.isLoading,
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
export default class ScratchpadRecordsTableContainer extends React.PureComponent {
|
||||
render() {
|
||||
const cn = classNames('data-table is--full-row scratchpad__records__table', {
|
||||
loading: this.props.isLoading,
|
||||
});
|
||||
return (
|
||||
<table className={cn}>
|
||||
<colgroup>
|
||||
<col className="col--detail" />
|
||||
<col className="col--memory" />
|
||||
<col className="col--time" />
|
||||
<col className="col--at" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{this.props.rows.map(rowId => (
|
||||
<ScratchpadRecordsRow key={rowId} id={rowId} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import request from 'vj/utils/request';
|
||||
import * as languageEnum from 'vj/constant/language';
|
||||
import Icon from 'vj/components/react/IconComponent';
|
||||
import Toolbar, {
|
||||
ToolbarItemComponent as ToolbarItem,
|
||||
ToolbarButtonComponent as ToolbarButton,
|
||||
ToolbarSplitComponent as ToolbarSplit,
|
||||
} from './ToolbarComponent';
|
||||
|
||||
function isTestCaseDataValid(data) {
|
||||
return data.input.trim().length > 0 && data.output.trim().length > 0;
|
||||
}
|
||||
|
||||
function isPretestValid(state) {
|
||||
return _.some(state.tabs, id => isTestCaseDataValid(state.data[id]));
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
pretestVisible: state.ui.pretest.visible,
|
||||
recordsVisible: state.ui.records.visible,
|
||||
isPosting: state.ui.isPosting,
|
||||
editorLang: state.editor.lang,
|
||||
pretestValid: isPretestValid(state.pretest),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
togglePanel(uiElement) {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_UI_TOGGLE_VISIBILITY',
|
||||
payload: { uiElement },
|
||||
});
|
||||
},
|
||||
setEditorLanguage(lang) {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_EDITOR_SET_LANG',
|
||||
payload: lang,
|
||||
});
|
||||
},
|
||||
postPretest(context) {
|
||||
const state = context.store.getState();
|
||||
const { pretest } = state;
|
||||
const testCases = pretest.tabs
|
||||
.filter(tabId => isTestCaseDataValid(pretest.data[tabId]));
|
||||
// const titles = testCases.map(tabId => pretest.meta[tabId].title);
|
||||
const inputs = testCases.map(tabId => pretest.data[tabId].input);
|
||||
const outputs = testCases.map(tabId => pretest.data[tabId].output);
|
||||
const req = request.post(Context.postPretestUrl, {
|
||||
lang: state.editor.lang,
|
||||
code: state.editor.code,
|
||||
data_input: inputs,
|
||||
data_output: outputs,
|
||||
});
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_POST_PRETEST',
|
||||
payload: req,
|
||||
});
|
||||
},
|
||||
postSubmit(context) {
|
||||
const state = context.store.getState();
|
||||
const req = request.post(Context.postSubmitUrl, {
|
||||
lang: state.editor.lang,
|
||||
code: state.editor.code,
|
||||
});
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_POST_SUBMIT',
|
||||
payload: req,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class ScratchpadToolbarContainer extends React.PureComponent {
|
||||
static contextTypes = {
|
||||
store: PropTypes.object,
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarButton
|
||||
disabled={this.props.isPosting || !this.props.pretestValid}
|
||||
className="scratchpad__toolbar__pretest"
|
||||
onClick={() => this.props.postPretest(this.context)}
|
||||
data-global-hotkey="f9"
|
||||
data-tooltip={`${i18n('Pretest Your Code')} (F9)`}
|
||||
>
|
||||
<Icon name="debug" />
|
||||
{' '}
|
||||
{i18n('Run Pretest')}
|
||||
{' '}
|
||||
(F9)
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
disabled={this.props.isPosting}
|
||||
className="scratchpad__toolbar__submit"
|
||||
onClick={() => this.props.postSubmit(this.context)}
|
||||
data-global-hotkey="f10"
|
||||
data-tooltip={`${i18n('Submit Your Code')} (F10)`}
|
||||
>
|
||||
<Icon name="play" />
|
||||
{' '}
|
||||
{i18n('Submit Solution')}
|
||||
{' '}
|
||||
(F10)
|
||||
</ToolbarButton>
|
||||
<ToolbarItem>
|
||||
<select
|
||||
className="select"
|
||||
disabled={this.props.isPosting}
|
||||
value={this.props.editorLang}
|
||||
onChange={ev => this.props.setEditorLanguage(ev.target.value)}
|
||||
>
|
||||
{_.map(languageEnum.LANG_TEXTS, (val, key) => (
|
||||
<option value={key} key={key}>{val}</option>
|
||||
))}
|
||||
</select>
|
||||
</ToolbarItem>
|
||||
<ToolbarSplit />
|
||||
<ToolbarButton
|
||||
activated={this.props.pretestVisible}
|
||||
onClick={() => this.props.togglePanel('pretest')}
|
||||
data-global-hotkey="alt+p"
|
||||
data-tooltip={`${i18n('Toggle Pretest Panel')} (Alt+P)`}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
{' '}
|
||||
{i18n('Pretest')}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
activated={this.props.recordsVisible}
|
||||
onClick={() => this.props.togglePanel('records')}
|
||||
data-global-hotkey="alt+r"
|
||||
data-tooltip={`${i18n('Toggle Records Panel')} (Alt+R)`}
|
||||
>
|
||||
<Icon name="flag" />
|
||||
{' '}
|
||||
{i18n('Records')}
|
||||
</ToolbarButton>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.scratchpad__toolbar
|
||||
background: $toolbar-bg
|
||||
padding: rem(8px)
|
||||
font-size: rem($font-size-small)
|
||||
font-weight: normal
|
||||
|
||||
.scratchpad__toolbar__item
|
||||
margin-right: rem(5px)
|
||||
|
||||
.select
|
||||
margin: 0
|
||||
height: auto
|
||||
line-height: 1
|
||||
|
||||
.scratchpad__toolbar__button
|
||||
padding: rem(5px)
|
||||
font-size: rem($font-size-small)
|
||||
font-weight: normal
|
||||
background: none
|
||||
border: 1px solid transparent
|
||||
color: $toolbar-fore
|
||||
outline: 0
|
||||
transition: .2s linear opacity
|
||||
|
||||
// Hack for Firefox bug #984869
|
||||
&>div
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
|
||||
&.enabled
|
||||
cursor: pointer
|
||||
|
||||
.icon
|
||||
font-size: rem($font-size-icon)
|
||||
margin-right: 2px
|
||||
|
||||
&.enabled:hover
|
||||
color: $toolbar-fore-hover
|
||||
background: $toolbar-bg-hover
|
||||
|
||||
&.activated
|
||||
color: $toolbar-fore-selected !important
|
||||
background: $toolbar-bg-selected !important
|
||||
|
||||
&.disabled
|
||||
pointer-events: none
|
||||
opacity: $toolbar-disabled-opacity
|
||||
|
||||
.scratchpad__toolbar__split
|
||||
width: 1px
|
||||
height: 1rem
|
||||
background: #DDD
|
||||
@@ -0,0 +1,90 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function ToolbarComponent(props) {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const cn = classNames(className, 'scratchpad__toolbar flex-row flex-cross-center');
|
||||
return (
|
||||
<div {...rest} className={cn}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolbarComponent.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export function ToolbarButtonComponent(props) {
|
||||
const {
|
||||
activated,
|
||||
disabled,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const cn = classNames(className, 'scratchpad__toolbar__item scratchpad__toolbar__button', {
|
||||
activated,
|
||||
disabled,
|
||||
enabled: !disabled,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
tabIndex="-1"
|
||||
className={cn}
|
||||
onClick={() => !disabled && onClick && onClick()}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
ToolbarButtonComponent.propTypes = {
|
||||
activated: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
ToolbarButtonComponent.defaultProps = {
|
||||
activated: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export function ToolbarSplitComponent(props) {
|
||||
const {
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
const cn = classNames(className, 'scratchpad__toolbar__item scratchpad__toolbar__split');
|
||||
return (
|
||||
<div {...rest} className={cn} />
|
||||
);
|
||||
}
|
||||
|
||||
ToolbarSplitComponent.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export function ToolbarItemComponent(props) {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const cn = classNames(className, 'scratchpad__toolbar__item');
|
||||
return (
|
||||
<div {...rest} className={cn}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolbarItemComponent.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import SplitPane from 'react-split-pane';
|
||||
import { connect } from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
import SplitPaneFillOverlay from 'vj/components/react-splitpane/SplitPaneFillOverlayComponent';
|
||||
import Dom from 'vj/components/react/DomComponent';
|
||||
import ScratchpadToolbar from './ScratchpadToolbarContainer';
|
||||
import ScratchpadEditor from './ScratchpadEditorContainer';
|
||||
import ScratchpadPretest from './ScratchpadPretestContainer';
|
||||
import ScratchpadRecords from './ScratchpadRecordsContainer';
|
||||
|
||||
/*
|
||||
a: React.Component
|
||||
panes: [{
|
||||
id: string,
|
||||
element: React.Component,
|
||||
props: {
|
||||
visible: bool,
|
||||
size: string|int,
|
||||
},
|
||||
}]
|
||||
*/
|
||||
function buildNestedPane([a, ...panes]) {
|
||||
const elements = [
|
||||
a,
|
||||
...panes.filter(p => p.props.visible),
|
||||
];
|
||||
if (elements.length === 1) {
|
||||
return a;
|
||||
}
|
||||
return elements
|
||||
.reduce((prev, curr) => (
|
||||
<SplitPane
|
||||
split="horizontal"
|
||||
primary="second"
|
||||
defaultSize={curr.props.size}
|
||||
key={curr.element.key}
|
||||
onChange={curr.onChange}
|
||||
>
|
||||
{prev}
|
||||
{curr.element}
|
||||
</SplitPane>
|
||||
));
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
ui: state.ui,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
changeUiSize: _.debounce((uiElement, size) => {
|
||||
dispatch({
|
||||
type: 'SCRATCHPAD_UI_CHANGE_SIZE',
|
||||
payload: {
|
||||
uiElement,
|
||||
size,
|
||||
},
|
||||
});
|
||||
}, 500),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class ScratchpadContainer extends React.PureComponent {
|
||||
handleChangeSize(uiElement, size) {
|
||||
this.props.changeUiSize(uiElement, size);
|
||||
$('#scratchpad').trigger('vjScratchpadRelayout');
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SplitPane
|
||||
defaultSize={this.props.ui.main.size}
|
||||
minSize={250}
|
||||
split="vertical"
|
||||
primary="second"
|
||||
onChange={size => this.handleChangeSize('main', size)}
|
||||
>
|
||||
<Dom className="scratchpad__problem" childDom={$('.problem-content').get(0)} />
|
||||
{buildNestedPane([
|
||||
<SplitPaneFillOverlay key="editor" className="flex-col">
|
||||
<ScratchpadToolbar />
|
||||
<ScratchpadEditor />
|
||||
</SplitPaneFillOverlay>,
|
||||
{
|
||||
props: this.props.ui.pretest,
|
||||
onChange: size => this.handleChangeSize('pretest', size),
|
||||
element: <ScratchpadPretest key="pretest" />,
|
||||
},
|
||||
{
|
||||
props: this.props.ui.records,
|
||||
onChange: size => this.handleChangeSize('records', size),
|
||||
element: <ScratchpadRecords key="records" />,
|
||||
},
|
||||
])}
|
||||
</SplitPane>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default function reducer(state = {
|
||||
lang: Context.code_lang,
|
||||
code: Context.code_template,
|
||||
}, action) {
|
||||
switch (action.type) {
|
||||
case 'SCRATCHPAD_EDITOR_UPDATE_CODE': {
|
||||
return {
|
||||
...state,
|
||||
code: action.payload,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_EDITOR_SET_LANG': {
|
||||
return {
|
||||
...state,
|
||||
lang: action.payload,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import ui from './ui';
|
||||
import editor from './editor';
|
||||
import pretest from './pretest';
|
||||
import records from './records';
|
||||
|
||||
const reducer = combineReducers({
|
||||
ui,
|
||||
editor,
|
||||
pretest,
|
||||
records,
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
@@ -0,0 +1,103 @@
|
||||
import uuid from 'uuid/v4';
|
||||
import _ from 'lodash';
|
||||
|
||||
const initialId = uuid();
|
||||
|
||||
export default function reducer(state = {
|
||||
counter: 1,
|
||||
current: initialId,
|
||||
tabs: [initialId],
|
||||
meta: {
|
||||
[initialId]: {
|
||||
id: initialId,
|
||||
title: '#1',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
[initialId]: {
|
||||
id: initialId,
|
||||
title: '#1',
|
||||
input: '',
|
||||
output: '',
|
||||
},
|
||||
},
|
||||
}, action) {
|
||||
switch (action.type) {
|
||||
case 'SCRATCHPAD_PRETEST_SWITCH_TO_DATA': {
|
||||
const currentId = action.payload;
|
||||
return {
|
||||
...state,
|
||||
current: currentId,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_PRETEST_ADD_DATA': {
|
||||
const newCounter = state.counter + 1;
|
||||
const newId = uuid();
|
||||
return {
|
||||
...state,
|
||||
counter: newCounter,
|
||||
current: newId,
|
||||
tabs: [...state.tabs, newId],
|
||||
meta: {
|
||||
...state.meta,
|
||||
[newId]: {
|
||||
id: newId,
|
||||
title: `#${newCounter}`,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...state.data,
|
||||
[newId]: {
|
||||
id: newId,
|
||||
input: '',
|
||||
output: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_PRETEST_REMOVE_DATA': {
|
||||
const orgIdx = state.tabs.indexOf(state.current);
|
||||
let newCounter = state.counter;
|
||||
const newTabs = _.without(state.tabs, state.current);
|
||||
const newMeta = _.omit(state.meta, state.current);
|
||||
const newData = _.omit(state.data, state.current);
|
||||
if (newTabs.length === 0) {
|
||||
// keep at least one data
|
||||
const id = uuid();
|
||||
newTabs.push(id);
|
||||
newMeta[id] = {
|
||||
id,
|
||||
title: `#${++newCounter}`,
|
||||
};
|
||||
newData[id] = {
|
||||
input: '',
|
||||
output: '',
|
||||
};
|
||||
}
|
||||
const newIdx = (orgIdx < newTabs.length) ? orgIdx : orgIdx - 1;
|
||||
return {
|
||||
...state,
|
||||
counter: newCounter,
|
||||
current: newTabs[newIdx],
|
||||
tabs: newTabs,
|
||||
meta: newMeta,
|
||||
data: newData,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_PRETEST_DATA_CHANGE': {
|
||||
const { id, type, value } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
[id]: {
|
||||
...state.data[id],
|
||||
[type]: value,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
export default function reducer(state = {
|
||||
rows: [],
|
||||
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;
|
||||
if (rdoc.uid !== UserContext.uid
|
||||
|| rdoc.domain_id !== UserContext.domain
|
||||
|| rdoc.pid !== Context.problemId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
rows: [rdoc._id, ...state.rows],
|
||||
items: {
|
||||
...state.items,
|
||||
[rdoc._id]: rdoc,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Notification from 'vj/components/notification';
|
||||
|
||||
export default function reducer(state = {
|
||||
main: {
|
||||
size: '50%',
|
||||
},
|
||||
pretest: {
|
||||
visible: false,
|
||||
size: 200,
|
||||
},
|
||||
records: {
|
||||
visible: true,
|
||||
size: 200,
|
||||
isLoading: false,
|
||||
},
|
||||
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;
|
||||
return {
|
||||
...state,
|
||||
[uiElement]: {
|
||||
...state[uiElement],
|
||||
visible: visibility,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': {
|
||||
const { uiElement } = action.payload;
|
||||
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': {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.scratchpad__sidebar
|
||||
z-index: 9999
|
||||
opacity: 0
|
||||
transition: opacity .2s linear
|
||||
mask-image: linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,0) 50%)
|
||||
|
||||
&.visible
|
||||
opacity: 0.3
|
||||
|
||||
&:hover
|
||||
opacity: 1
|
||||
mask-image: none
|
||||
|
||||
.scratchpad-container
|
||||
position: absolute
|
||||
background: #FFF
|
||||
|
||||
#scratchpad
|
||||
position: absolute
|
||||
left: 0
|
||||
top: $nav-item-height
|
||||
width: 100%
|
||||
bottom: 0
|
||||
|
||||
.scratchpad__problem
|
||||
position: absolute
|
||||
left: 0
|
||||
top: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
overflow: auto
|
||||
|
||||
.scratchpad__toolbar__pretest.enabled:hover .icon
|
||||
color: red
|
||||
|
||||
.scratchpad__toolbar__submit.enabled:hover .icon
|
||||
color: green
|
||||
|
||||
.scratchpad__records__table
|
||||
&.loading
|
||||
// make a flash by setting opacity, otherwise users
|
||||
// won't think that refreshing is completed!
|
||||
opacity: 0.6
|
||||
|
||||
.col--at
|
||||
width: rem(120px)
|
||||
|
||||
.col--time,
|
||||
.col--memory
|
||||
width: rem(70px)
|
||||
|
||||
.col--detail
|
||||
border-right: 1px solid $table-border-color
|
||||
|
||||
.icol
|
||||
display: inline-block
|
||||
|
||||
.icol--pretest
|
||||
width: rem(60px)
|
||||
|
||||
.flag
|
||||
font-size: rem(12px)
|
||||
color: #FFF
|
||||
padding: rem(2px 3px)
|
||||
|
||||
.icol--stat
|
||||
color: #AAA
|
||||
width: rem(60px)
|
||||
@@ -0,0 +1,2 @@
|
||||
$panel-header-height = 34px
|
||||
$tab-color = #fff170
|
||||
Reference in new issue
Block a user