').appendTo('#messages');
+ $(`
+
+ `)
+ .appendTo(div);
+ };
+});
+
+export default page;
diff --git a/ui/pages/problem_copy.page.js b/ui/pages/problem_copy.page.js
new file mode 100644
index 000000000..b0abf1e7b
--- /dev/null
+++ b/ui/pages/problem_copy.page.js
@@ -0,0 +1,8 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+import DomainSelectAutoComplete from 'vj/components/autocomplete/DomainSelectAutoComplete';
+
+const page = new NamedPage('problem_copy', async () => {
+ DomainSelectAutoComplete.getOrConstruct($('.section__body [name="src_domain_id"]'));
+});
+
+export default page;
diff --git a/ui/pages/problem_detail.page.js b/ui/pages/problem_detail.page.js
new file mode 100644
index 000000000..41f7207e5
--- /dev/null
+++ b/ui/pages/problem_detail.page.js
@@ -0,0 +1,297 @@
+import Tether from 'tether';
+import { NamedPage } from 'vj/misc/PageLoader';
+import Navigation from 'vj/components/navigation';
+import Notification from 'vj/components/notification';
+import { ActionDialog } from 'vj/components/dialog';
+import DomainSelectAutoComplete from 'vj/components/autocomplete/DomainSelectAutoComplete';
+import loadReactRedux from 'vj/utils/loadReactRedux';
+import delay from 'vj/utils/delay';
+import request from 'vj/utils/request';
+import i18n from 'vj/utils/i18n';
+
+class ProblemPageExtender {
+ constructor() {
+ this.isExtended = false;
+ this.inProgress = false;
+ this.$content = $('.problem-content-container');
+ this.$contentBound = this.$content.closest('.section');
+ this.$scratchpadContainer = $('.scratchpad-container');
+ }
+
+ async extend() {
+ if (this.inProgress) {
+ return;
+ }
+ if (this.isExtended) {
+ return;
+ }
+ this.inProgress = true;
+
+ const bound = this.$contentBound
+ .get(0)
+ .getBoundingClientRect();
+
+ this.$content.transition({ opacity: 0 }, { duration: 100 });
+ await delay(100);
+
+ Navigation.instance.floating.set('scratchpad', true);
+ Navigation.instance.logoVisible.set('scratchpad', true);
+ Navigation.instance.expanded.set('scratchpad', true);
+ $('body').addClass('header--collapsed mode--scratchpad');
+
+ await this.$scratchpadContainer
+ .css({
+ left: bound.left,
+ top: bound.top,
+ width: bound.width,
+ height: bound.height,
+ })
+ .show()
+ .transition({
+ left: 0,
+ top: 0,
+ width: '100%',
+ height: '100%',
+ }, {
+ duration: 500,
+ easing: 'easeOutCubic',
+ })
+ .promise();
+
+ $('.main > .row').hide();
+ $('.footer').hide();
+ $(window).scrollTop(0);
+
+ this.inProgress = false;
+ this.isExtended = true;
+ }
+
+ async collapse() {
+ if (this.inProgress) {
+ return;
+ }
+ if (!this.isExtended) {
+ return;
+ }
+ this.inProgress = true;
+
+ $(window).scrollTop(0);
+ $('.main > .row').show();
+ $('.footer').show();
+
+ const bound = this.$contentBound
+ .get(0)
+ .getBoundingClientRect();
+
+ Navigation.instance.floating.set('scratchpad', false);
+ Navigation.instance.logoVisible.set('scratchpad', false);
+ Navigation.instance.expanded.set('scratchpad', false);
+
+ $('body').removeClass('header--collapsed mode--scratchpad');
+
+ await this.$scratchpadContainer
+ .transition({
+ left: bound.left,
+ top: bound.top,
+ width: bound.width,
+ height: bound.height,
+ }, {
+ duration: 500,
+ easing: 'easeOutCubic',
+ })
+ .promise();
+
+ this.$scratchpadContainer.hide();
+ this.$content.transition({ opacity: 1 }, { duration: 100 });
+
+ this.inProgress = false;
+ this.isExtended = false;
+ }
+
+ toggle() {
+ if (this.isExtended) {
+ this.collapse();
+ } else {
+ this.extend();
+ }
+ }
+}
+
+const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homework_detail_problem'], () => {
+ let reactLoaded = false;
+ let $floatingSidebar = null;
+ let renderReact = null;
+ let unmountReact = null;
+ const extender = new ProblemPageExtender();
+
+ async function scratchpadFadeIn() {
+ await $('#scratchpad')
+ .transition({
+ opacity: 1,
+ }, {
+ duration: 200,
+ easing: 'easeOutCubic',
+ })
+ .promise();
+ }
+
+ async function scratchpadFadeOut() {
+ await $('#scratchpad')
+ .transition({
+ opacity: 0,
+ }, {
+ duration: 200,
+ easing: 'easeOutCubic',
+ })
+ .promise();
+ }
+
+ function updateFloatingSidebar() {
+ $floatingSidebar.tether.position();
+ }
+
+ async function createSidebar() {
+ $floatingSidebar = $('.section--problem-sidebar')
+ .clone()
+ .addClass('scratchpad__sidebar visible')
+ .appendTo('body');
+ $floatingSidebar.find('a').attr('target', '_blank');
+ $floatingSidebar.tether = new Tether({
+ element: $floatingSidebar,
+ offset: '-20px 20px',
+ target: '.scratchpad__problem',
+ attachment: 'top right',
+ targetAttachment: 'top right',
+ });
+ await delay(100);
+ $floatingSidebar.tether.position();
+ }
+
+ async function removeSidebar() {
+ $floatingSidebar.tether.destroy();
+ $floatingSidebar.remove();
+ $floatingSidebar = null;
+ }
+
+ async function loadReact() {
+ if (reactLoaded) {
+ return;
+ }
+
+ $('.loader-container').show();
+
+ const SockJs = await import('sockjs-client');
+ 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 sock = new SockJs(Context.socketUrl);
+ sock.onmessage = (message) => {
+ const msg = JSON.parse(message.data);
+ store.dispatch({
+ type: 'SCRATCHPAD_RECORDS_PUSH',
+ payload: msg,
+ });
+ };
+
+ renderReact = () => {
+ render(
+
+
+ ,
+ $('#scratchpad').get(0),
+ );
+ };
+
+ unmountReact = () => {
+ unmountComponentAtNode($('#scratchpad').get(0));
+ };
+
+ reactLoaded = true;
+
+ $('.loader-container').hide();
+ }
+
+ const domainSelector = DomainSelectAutoComplete.getOrConstruct($('.dialog__body--copy-to [name="domain_id"]'));
+ const copyProblemToDialog = new ActionDialog({
+ $body: $('.dialog__body--copy-to > div'),
+ onDispatch(action) {
+ if (action === 'ok' && domainSelector.value() === null) {
+ domainSelector.focus();
+ return false;
+ }
+ return true;
+ },
+ });
+ copyProblemToDialog.clear = function () {
+ domainSelector.clear();
+ return this;
+ };
+
+
+ async function handleClickCopyProblem() {
+ const action = await copyProblemToDialog.clear().open();
+ if (action !== 'ok') {
+ return;
+ }
+ const domainId = copyProblemToDialog.$dom.find('[name="domain_id"]').val();
+ const useNumericId = copyProblemToDialog.$dom.find('[name="numeric_pid"]').prop('checked');
+ const isHidden = copyProblemToDialog.$dom.find('[name="hidden"]').prop('checked');
+ const payload = {
+ operation: 'copy_to_domain',
+ dest_domain_id: domainId,
+ };
+ if (useNumericId) {
+ payload.numeric_pid = 'on';
+ }
+ if (isHidden) {
+ payload.hidden = 'on';
+ }
+ try {
+ const data = await request.post('', payload);
+ Notification.success(i18n('Problem is successfully copied.'));
+ await delay(1000);
+ window.location.href = data.new_problem_url;
+ } catch (error) {
+ Notification.error(error.message);
+ }
+ }
+
+ async function enterScratchpadMode() {
+ await extender.extend();
+ await loadReact();
+ renderReact();
+ await scratchpadFadeIn();
+ await createSidebar();
+ }
+
+ async function leaveScratchpadMode() {
+ await removeSidebar();
+ await scratchpadFadeOut();
+ $('.problem-content-container').append($('.problem-content'));
+ await extender.collapse();
+ unmountReact();
+ }
+
+ $(document).on('vjScratchpadRelayout', updateFloatingSidebar);
+ $(document).on('click', '[name="problem-sidebar__open-scratchpad"]', (ev) => {
+ enterScratchpadMode();
+ ev.preventDefault();
+ });
+ $(document).on('click', '[name="problem-sidebar__quit-scratchpad"]', (ev) => {
+ leaveScratchpadMode();
+ ev.preventDefault();
+ });
+ $(document).on('click', '[name="problem-sidebar__copy-to"]', (ev) => {
+ handleClickCopyProblem();
+ ev.preventDefault();
+ });
+ $(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
+ $(ev.currentTarget).hide();
+ $('[name="problem-sidebar__categories"]').show();
+ });
+});
+
+export default page;
diff --git a/ui/pages/problem_edit.page.js b/ui/pages/problem_edit.page.js
new file mode 100644
index 000000000..c8ba60fcf
--- /dev/null
+++ b/ui/pages/problem_edit.page.js
@@ -0,0 +1,10 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+
+const page = new NamedPage('problem_edit', async () => {
+ $(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
+ $(ev.currentTarget).hide();
+ $('[name="problem-sidebar__categories"]').show();
+ });
+});
+
+export default page;
diff --git a/ui/pages/problem_main.page.js b/ui/pages/problem_main.page.js
new file mode 100644
index 000000000..7cd071fe3
--- /dev/null
+++ b/ui/pages/problem_main.page.js
@@ -0,0 +1,171 @@
+import _ from 'lodash';
+
+import { NamedPage } from 'vj/misc/PageLoader';
+import Dropdown from 'vj/components/dropdown/Dropdown';
+import pjax from 'vj/utils/pjax';
+import substitute from 'vj/utils/substitute';
+
+const categories = {};
+const dirtyCategories = [];
+const selections = [];
+
+function setDomSelected($dom, selected) {
+ if (selected) {
+ $dom.addClass('selected');
+ } else {
+ $dom.removeClass('selected');
+ }
+}
+
+async function updateSelection(sendRequest = true) {
+ dirtyCategories.forEach(({ type, category, subcategory }) => {
+ let item = categories[category];
+ const isSelected = item.select || _.some(item.children, c => c.select);
+ setDomSelected(item.$tag, isSelected);
+ if (isSelected) {
+ selections.push(category);
+ } else {
+ _.pull(selections, category);
+ }
+ if (type === 'subcategory') {
+ item = categories[category].children[subcategory];
+ setDomSelected(item.$tag, item.select);
+ const selectionName = `${category},${subcategory}`;
+ if (item.select) {
+ selections.push(selectionName);
+ } else {
+ _.pull(selections, selectionName);
+ }
+ }
+ });
+ dirtyCategories.length = 0;
+
+ if (sendRequest) {
+ // a list of categories which subcategory is selected
+ const requestCategoryTags = _.uniq(selections
+ .filter(s => s.indexOf(',') !== -1)
+ .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 = Context.getProblemUrlWithoutCategory;
+ } else {
+ url = substitute(decodeURIComponent(Context.getProblemUrlWithCategory), {
+ category: requestTags
+ .map(tag => tag.split(',').map(encodeURIComponent).join(','))
+ .join('+'), // build a beautiful URL
+ });
+ }
+ pjax.request({ url });
+ }
+}
+
+function buildCategoryFilter() {
+ const $container = $('[data-widget-cf-container]');
+ if (!$container) {
+ return;
+ }
+ $container.attr('class', 'widget--category-filter row small-up-3 medium-up-2');
+ $container.children('li').get().forEach((category) => {
+ const $category = $(category)
+ .attr('class', 'widget--category-filter__category column');
+ const $categoryTag = $category
+ .find('.section__title a')
+ .remove()
+ .attr('class', 'widget--category-filter__category-tag');
+ const categoryText = $categoryTag.text();
+ const $drop = $category
+ .children('.chip-list')
+ .remove()
+ .attr('class', 'widget--category-filter__drop');
+ const treeItem = {
+ select: false,
+ $tag: $categoryTag,
+ children: {},
+ };
+ categories[categoryText] = treeItem;
+ $category.empty().append($categoryTag);
+ if ($drop.length > 0) {
+ $categoryTag.text(`${$categoryTag.text()}`);
+ const $subCategoryTags = $drop
+ .children('li')
+ .attr('class', 'widget--category-filter__subcategory')
+ .find('a')
+ .attr('class', 'widget--category-filter__subcategory-tag')
+ .attr('data-category', categoryText);
+ $subCategoryTags.get().forEach((subCategoryTag) => {
+ const $tag = $(subCategoryTag);
+ treeItem.children[$tag.text()] = {
+ select: false,
+ $tag,
+ };
+ });
+ Dropdown.getOrConstruct($categoryTag, {
+ target: $drop[0],
+ position: 'left center',
+ });
+ }
+ });
+ $(document).on('click', '.widget--category-filter__category-tag', (ev) => {
+ if (ev.shiftKey || ev.metaKey || ev.ctrlKey) {
+ return;
+ }
+ const category = $(ev.currentTarget).text();
+ const treeItem = categories[category];
+ // the effect should be cancelSelect if it is shown as selected when clicking
+ const shouldSelect = treeItem.$tag.hasClass('selected') ? false : !treeItem.select;
+ treeItem.select = shouldSelect;
+ dirtyCategories.push({ type: 'category', category });
+ if (!shouldSelect) {
+ // de-select children
+ _.forEach(treeItem.children, (treeSubItem, subcategory) => {
+ if (treeSubItem.select) {
+ treeSubItem.select = false; // eslint-disable-line no-param-reassign
+ dirtyCategories.push({ type: 'subcategory', subcategory, category });
+ }
+ });
+ }
+ updateSelection();
+ ev.preventDefault();
+ });
+ $(document).on('click', '.widget--category-filter__subcategory-tag', (ev) => {
+ if (ev.shiftKey || ev.metaKey || ev.ctrlKey) {
+ return;
+ }
+ const subcategory = $(ev.currentTarget).text();
+ const category = $(ev.currentTarget).attr('data-category');
+ const treeItem = categories[category].children[subcategory];
+ treeItem.select = !treeItem.select;
+ dirtyCategories.push({ type: 'subcategory', subcategory, category });
+ updateSelection();
+ ev.preventDefault();
+ });
+}
+
+function parseCategorySelection() {
+ Context.currentCategory.split(' ').forEach((cline) => {
+ const [category, subcategory] = cline.split(',');
+ if (!categories[category]) {
+ return;
+ }
+ if (subcategory && !categories[category].children[subcategory]) {
+ return;
+ }
+ if (!subcategory) {
+ categories[category].select = true;
+ dirtyCategories.push({ type: 'category', category });
+ } else {
+ categories[category].children[subcategory].select = true;
+ dirtyCategories.push({ type: 'subcategory', subcategory, category });
+ }
+ });
+ updateSelection(false);
+}
+
+const page = new NamedPage(['problem_main', 'problem_category'], () => {
+ buildCategoryFilter();
+ parseCategorySelection();
+});
+
+export default page;
diff --git a/ui/pages/problem_main.page.styl b/ui/pages/problem_main.page.styl
new file mode 100644
index 000000000..3e927663e
--- /dev/null
+++ b/ui/pages/problem_main.page.styl
@@ -0,0 +1,98 @@
+.page--problem_main
+.page--problem_category
+ .col--status
+ width: rem(150px)
+ position: relative
+
+ .col--submit_n, .col--ac_rate, .col--difficulty
+ width: rem(70px)
+ text-align: center
+
+ .col--status
+ border-right: 1px solid $table-border-color
+
+ .col--submit_n, .col--difficulty
+ border-left: 1px solid $table-border-color
+
+ +mobile()
+ .col--status,
+ .col--submit_n,
+ .col--difficulty
+ display: none
+
+ .col--ac_rate
+ border-left: 1px solid $table-border-color
+
+
+ .widget--category-filter
+ &__category
+ margin-bottom: rem(6px)
+
+ &-tag
+ display: inline-block
+ padding: rem(5px 6px)
+
+ &:hover
+ color: #FFF
+ background: $primary-color
+ text-decoration: none
+
+ &.selected
+ color: #FFF
+ background: $secondary-color
+
+ &__drop
+ position: relative
+ font-size: rem($font-size-small)
+ width: rem(170px)
+ padding: rem(10px)
+ margin-right: rem(10px)
+ background: $menu-drop-bg-color
+ box-shadow: $menu-drop-shadow
+
+ &:before
+ content: ''
+ display: block
+ position: absolute
+ width: 0
+ height: 0
+ border: rem($menu-drop-triangle-size) solid transparent
+ border-top-color: #FFF
+ border-left-color: #FFF
+ box-shadow: $menu-drop-triangle-shadow
+ transform: rotate(135deg)
+ transform-origin: 0 0
+ pointer-events: none
+ left: 100%
+ top: 50%
+ margin-left: rem(floor($menu-drop-triangle-size * 1.414))
+
+ &__subcategory
+ margin: rem(2px)
+ display: inline-block
+
+ &-tag
+ display: inline-block
+ padding: rem(5px 6px)
+
+ &:hover
+ color: #FFF
+ background: $primary-color
+ text-decoration: none
+
+ &.selected
+ color: #FFF
+ background: $secondary-color
+
+ .drop-target-attached-right .widget--category-filter__drop
+ margin-right: 0
+ margin-left: rem(10px)
+
+ &:before
+ transform-origin: 100% 0
+ transform: rotate(-45deg)
+ left: auto
+ right: 100%
+ margin-left: 0
+ margin-right: 0
+ margin-top: rem(floor($menu-drop-triangle-size * -1.414))
diff --git a/ui/pages/problem_settings.page.js b/ui/pages/problem_settings.page.js
new file mode 100644
index 000000000..f52d67f1e
--- /dev/null
+++ b/ui/pages/problem_settings.page.js
@@ -0,0 +1,19 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+
+async function handleCategoryClick(ev) {
+ const $target = $(ev.currentTarget);
+ const $txt = $('[name="category"]');
+ console.log($target);
+ console.log($txt);
+ $txt.val(`${$txt.val()}, ${$target.data('category')}`);
+}
+
+const page = new NamedPage('problem_settings', async () => {
+ $(document).on('click', '.category-a', handleCategoryClick);
+ $(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
+ $(ev.currentTarget).hide();
+ $('[name="problem-sidebar__categories"]').show();
+ });
+});
+
+export default page;
diff --git a/ui/pages/problem_solution.page.js b/ui/pages/problem_solution.page.js
new file mode 100644
index 000000000..8b39739ce
--- /dev/null
+++ b/ui/pages/problem_solution.page.js
@@ -0,0 +1,10 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+
+const page = new NamedPage('problem_solution', async () => {
+ $(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
+ $(ev.currentTarget).hide();
+ $('[name="problem-sidebar__categories"]').show();
+ });
+});
+
+export default page;
diff --git a/ui/pages/problem_submit.page.js b/ui/pages/problem_submit.page.js
new file mode 100644
index 000000000..4c2e98f7f
--- /dev/null
+++ b/ui/pages/problem_submit.page.js
@@ -0,0 +1,10 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+
+const page = new NamedPage('problem_submit', async () => {
+ $(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
+ $(ev.currentTarget).hide();
+ $('[name="problem-sidebar__categories"]').show();
+ });
+});
+
+export default page;
diff --git a/ui/pages/problem_submit.page.styl b/ui/pages/problem_submit.page.styl
new file mode 100644
index 000000000..cb47c4c12
--- /dev/null
+++ b/ui/pages/problem_submit.page.styl
@@ -0,0 +1,12 @@
+.page--problem_submit
+ .col--status
+ width: rem(150px)
+
+ .col--submit-at
+ width: rem(160px)
+ text-align: center
+
+ +mobile()
+ .col--memory,
+ .col--time
+ display: none
diff --git a/ui/pages/record_detail.page.js b/ui/pages/record_detail.page.js
new file mode 100644
index 000000000..5dfa38f8e
--- /dev/null
+++ b/ui/pages/record_detail.page.js
@@ -0,0 +1,21 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+
+const page = new NamedPage('record_detail', async () => {
+ const SockJs = await import('sockjs-client');
+ const { DiffDOM } = await import('diff-dom');
+
+ const sock = new SockJs(Context.socketUrl);
+ const dd = new DiffDOM();
+
+ sock.onmessage = (message) => {
+ const msg = JSON.parse(message.data);
+ const newStatus = $(msg.status_html);
+ const oldStatus = $('#status');
+ dd.apply(oldStatus[0], dd.diff(oldStatus[0], newStatus[0]));
+ const newSummary = $(msg.summary_html);
+ const oldSummary = $('#summary');
+ dd.apply(oldSummary[0], dd.diff(oldSummary[0], newSummary[0]));
+ };
+});
+
+export default page;
diff --git a/ui/pages/record_detail.page.styl b/ui/pages/record_detail.page.styl
new file mode 100644
index 000000000..afe6a8fc3
--- /dev/null
+++ b/ui/pages/record_detail.page.styl
@@ -0,0 +1,26 @@
+.page--record_detail
+
+ .compiler-text
+ padding: rem(20px 0)
+ font-size: rem($font-size-small)
+ &:empty
+ display: none
+
+ .col--case
+ width: rem(70px)
+
+ .col--memory, .col--time
+ width: rem(120px)
+
+ .col--case, .col--status
+ border-right: 1px solid $table-border-color
+
+ +mobile()
+ .col--case
+ display: none
+
+ .col--time
+ width: rem(70px)
+
+ .col--memory
+ width: rem(100px)
diff --git a/ui/pages/record_main.page.js b/ui/pages/record_main.page.js
new file mode 100644
index 000000000..4cc75b8cc
--- /dev/null
+++ b/ui/pages/record_main.page.js
@@ -0,0 +1,29 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete';
+
+const page = new NamedPage('record_main', async () => {
+ const SockJs = await import('sockjs-client');
+ const { DiffDOM } = await import('diff-dom');
+
+ const sock = new SockJs(Context.socketUrl);
+ const dd = new DiffDOM();
+
+ sock.onmessage = (message) => {
+ const msg = JSON.parse(message.data);
+ const $newTr = $(msg.html);
+ const $oldTr = $(`.record_main__table tr[data-rid="${$newTr.attr('data-rid')}"]`);
+ if ($oldTr.length) {
+ $oldTr.trigger('vjContentRemove');
+ dd.apply($oldTr[0], dd.diff($oldTr[0], $newTr[0]));
+ $oldTr.trigger('vjContentNew');
+ } else {
+ $('.record_main__table tbody').prepend($newTr);
+ $newTr.trigger('vjContentNew');
+ }
+ };
+ UserSelectAutoComplete.getOrConstruct($('.filter-user [name="uid_or_name"]'), {
+ clearDefaultValue: false,
+ });
+});
+
+export default page;
diff --git a/ui/pages/record_main.page.styl b/ui/pages/record_main.page.styl
new file mode 100644
index 000000000..7b0f95a13
--- /dev/null
+++ b/ui/pages/record_main.page.styl
@@ -0,0 +1,55 @@
+.page--record_main
+ .col--status
+ width: rem(170px)
+ position: relative
+
+ .col--submit-at
+ width: rem(120px)
+ text-align: center
+
+ &.nojs .col--submit-at
+ width: rem(160px)
+
+ .col--submit-by
+ width: rem(120px)
+ text-align: right
+
+ .col--time, .col--memory, .col--lang
+ width: rem(100px)
+ text-align: center
+
+ .col--status, .col--submit-by, .col--lang
+ border-right: 1px solid $table-border-color
+
+ .col--status__text
+ position: relative
+ z-index: 2
+
+ .col--status__progress-container
+ position: absolute
+ left: 0
+ top: 0
+ width: 100%
+ height: 100%
+ z-index: 1
+
+ .col--status__progress
+ position: absolute
+ left: 0
+ top: 0
+ height: 100%
+ background: #fff6d1
+
+ +tablet()
+ .col--time,
+ .col--memory,
+ .col--lang
+ display: none
+
+ +mobile()
+ .col--submit-by,
+ .col--submit-at,
+ .col--time,
+ .col--memory,
+ .col--lang
+ display: none
diff --git a/ui/pages/training_detail.page.js b/ui/pages/training_detail.page.js
new file mode 100644
index 000000000..7941e94c2
--- /dev/null
+++ b/ui/pages/training_detail.page.js
@@ -0,0 +1,35 @@
+import { NamedPage } from 'vj/misc/PageLoader';
+import { slideDown, slideUp } from 'vj/utils/slide';
+
+async function handleSectionExpand(ev) {
+ const $section = $(ev.currentTarget).closest('.training__section');
+ if ($section.is('.expanded, .animating')) {
+ return;
+ }
+ $section.addClass('animating');
+ const $detail = $section.find('.training__section__detail');
+ await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 });
+ $section.addClass('expanded');
+ $section.removeClass('collapsed');
+ $section.removeClass('animating');
+}
+
+async function handleSectionCollapse(ev) {
+ const $section = $(ev.currentTarget).closest('.training__section');
+ if ($section.is('.collapsed, .animating')) {
+ return;
+ }
+ $section.addClass('animating');
+ const $detail = $section.find('.training__section__detail');
+ await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 });
+ $section.addClass('collapsed');
+ $section.removeClass('expanded');
+ $section.removeClass('animating');
+}
+
+const page = new NamedPage('training_detail', async () => {
+ $(document).on('click', '[name="training__section__expand"]', handleSectionExpand);
+ $(document).on('click', '[name="training__section__collapse"]', handleSectionCollapse);
+});
+
+export default page;
diff --git a/ui/pages/training_detail.page.styl b/ui/pages/training_detail.page.styl
new file mode 100644
index 000000000..636d0b1f6
--- /dev/null
+++ b/ui/pages/training_detail.page.styl
@@ -0,0 +1,35 @@
+.page--training_detail
+ .col--status
+ width: rem(150px)
+ position: relative
+
+ .col--submit_n, .col--ac_rate, .col--difficulty
+ width: rem(70px)
+ text-align: center
+
+ .col--status
+ border-right: 1px solid $table-border-color
+
+ .col--submit_n, .col--difficulty
+ border-left: 1px solid $table-border-color
+
+ +mobile()
+ .col--status,
+ .col--submit_n,
+ .col--difficulty
+ display: none
+
+ .col--ac_rate
+ border-left: 1px solid $table-border-color
+
+ .training__section
+ &.invalid
+ .training__problems
+ opacity: 0.6
+
+ &.collapsed .collapsed--hidden,
+ &.expanded .expanded--hidden
+ display: none
+
+ &.collapsed .training__section__detail
+ display: none
diff --git a/ui/pages/training_main.page.styl b/ui/pages/training_main.page.styl
new file mode 100644
index 000000000..f92f62145
--- /dev/null
+++ b/ui/pages/training_main.page.styl
@@ -0,0 +1,48 @@
+.page--training_main
+ .training__title
+ line-height: 1.2
+ margin-bottom: 0.4em
+ font-size: rem($font-size-title)
+
+ .training__intro
+ color: lighten($text-1-color, 20%)
+ font-size: rem($font-size-secondary)
+ margin: 1em 0
+
+ .training__item
+ padding-left: rem(5px)
+
+ .training__participants
+ width: rem(80px)
+
+ .training__progress
+ line-height: rem(17px)
+ vertical-align: top
+
+ .training__progress-bar
+ display: inline-block
+ width: rem(100px)
+ height: rem(5px)
+ position: relative
+ background: #EEE
+ margin: rem(6px 0)
+ line-height: 1
+ vertical-align: top
+
+ .training__progress-track
+ position: absolute
+ left: 0
+ top: 0
+ height: 100%
+ width: 0%
+ background-color: green
+
+ .secondary.training__list
+ .training__item
+ margin-bottom: rem(15px)
+ .training__title
+ font-size: rem($font-size)
+
+ &.my
+ .training-status--icon
+ font-size: rem($font-size-icon * 1.2)
diff --git a/ui/pages/user_detail.page.js b/ui/pages/user_detail.page.js
new file mode 100644
index 000000000..cc54d0169
--- /dev/null
+++ b/ui/pages/user_detail.page.js
@@ -0,0 +1,23 @@
+import base64 from 'base-64';
+import Clipboard from 'clipboard';
+
+import { NamedPage } from 'vj/misc/PageLoader';
+import substitute from 'vj/utils/substitute';
+import Notification from 'vj/components/notification';
+import i18n from 'vj/utils/i18n';
+
+const page = new NamedPage('user_detail', async () => {
+ $('[name="profile_contact_copy"]').get().forEach((el) => {
+ const data = $(el).attr('data-content');
+ const decoded = base64.decode(data);
+ const clip = new Clipboard(el, { text: () => decoded });
+ clip.on('success', () => {
+ Notification.success(substitute(i18n('"{data}" copied to clipboard!'), { data: decoded }), 2000);
+ });
+ clip.on('error', () => {
+ Notification.error(substitute(i18n('Copy "{data}" failed :('), { data: decoded }));
+ });
+ });
+});
+
+export default page;
diff --git a/ui/pages/user_detail.page.styl b/ui/pages/user_detail.page.styl
new file mode 100644
index 000000000..fc9ad0cc6
--- /dev/null
+++ b/ui/pages/user_detail.page.styl
@@ -0,0 +1,90 @@
+@import '~vj/components/tab/var.inc.styl'
+
+.page--user_detail
+
+ .profile-header
+ position: relative
+ background-position: center center
+ background-size: cover
+ min-height: rem(270px)
+ padding-bottom: rem($tab-item-height + $tab-item-shadow-size + 20px)
+
+ +mobile()
+ padding-bottom: rem($tab-item-height-mobile)
+
+ .profile-header__content
+ position: relative
+ padding: rem($section-gap-v) rem($section-gap-h) 0
+
+ .media__left
+ padding-right: rem(30px)
+
+ +mobile()
+ .media, .media__left, .media__body
+ display: block
+
+ .media__left
+ text-align: center
+
+ img
+ display: inline-block
+
+ .media__body
+ margin-top: rem(20px)
+
+ .profile-header__contact-bar
+ position: absolute
+ right: rem($section-gap-h + 5px)
+ top: rem($section-gap-v + 20px)
+ color: #FFF
+ text-align: right
+
+ +below(rupture.hd-cutoff)
+ position: relative
+ right: 0
+ top: 0
+ text-align: left
+ margin: rem(15px 0)
+
+ .profile-header__contact-item
+ vertical-align: top
+ color: rgba(#FFF, 0.8)
+ margin-left: rem(10px)
+
+ +below(rupture.hd-cutoff)
+ margin-left: 0
+ margin-right: rem(15px)
+
+ &:hover
+ color: rgba(#FFF, 1)
+ text-decoration: none
+
+ &.badge--mod
+ background-color: rgba(#FFF, 0.8) !important
+ color: rgba(#000, 0.8) !important
+
+ .profile-header__main
+ h1
+ line-height: 40px
+ color: #FFF
+
+ p
+ line-height: 30px
+ color: #FFF
+
+ small
+ color: rgba(#FFF, 0.8)
+
+ .profile-content
+ position: relative
+ z-index: 2
+ margin-top: rem(-($tab-item-height + $tab-item-shadow-size))
+
+ +mobile()
+ margin-top: rem(-($tab-item-height-mobile))
+
+ .section__tabs
+ padding-top: rem($tab-item-height + $tab-item-shadow-size)
+
+ +mobile()
+ padding-top: rem($tab-item-height-mobile)
diff --git a/ui/postcss.config.js b/ui/postcss.config.js
new file mode 100644
index 000000000..a47ef4f95
--- /dev/null
+++ b/ui/postcss.config.js
@@ -0,0 +1,5 @@
+module.exports = {
+ plugins: {
+ autoprefixer: {},
+ },
+};
diff --git a/ui/static/android-chrome-192x192.png b/ui/static/android-chrome-192x192.png
new file mode 100644
index 000000000..cc8649532
Binary files /dev/null and b/ui/static/android-chrome-192x192.png differ
diff --git a/ui/static/apple-touch-icon-180x180.png b/ui/static/apple-touch-icon-180x180.png
new file mode 100644
index 000000000..86ee9981f
Binary files /dev/null and b/ui/static/apple-touch-icon-180x180.png differ
diff --git a/ui/static/favicon-16x16.png b/ui/static/favicon-16x16.png
new file mode 100644
index 000000000..e3ce5be71
Binary files /dev/null and b/ui/static/favicon-16x16.png differ
diff --git a/ui/static/favicon-32x32.png b/ui/static/favicon-32x32.png
new file mode 100644
index 000000000..400471089
Binary files /dev/null and b/ui/static/favicon-32x32.png differ
diff --git a/ui/static/favicon-96x96.png b/ui/static/favicon-96x96.png
new file mode 100644
index 000000000..8b34ff173
Binary files /dev/null and b/ui/static/favicon-96x96.png differ
diff --git a/ui/static/favicon.ico b/ui/static/favicon.ico
new file mode 100644
index 000000000..4ce7fcfef
Binary files /dev/null and b/ui/static/favicon.ico differ
diff --git a/ui/static/img/avatar.png b/ui/static/img/avatar.png
new file mode 100644
index 000000000..f88c8ce07
Binary files /dev/null and b/ui/static/img/avatar.png differ
diff --git a/ui/static/img/team_avatar.png b/ui/static/img/team_avatar.png
new file mode 100644
index 000000000..951384f5e
Binary files /dev/null and b/ui/static/img/team_avatar.png differ
diff --git a/ui/static/manifest.json b/ui/static/manifest.json
new file mode 100644
index 000000000..b7436a8fe
--- /dev/null
+++ b/ui/static/manifest.json
@@ -0,0 +1,11 @@
+{
+ "name": "Vijos",
+ "icons": [
+ {
+ "src": "\/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image\/png",
+ "density": 4
+ }
+ ]
+}
diff --git a/ui/static/mstile-144x144.png b/ui/static/mstile-144x144.png
new file mode 100644
index 000000000..c6b2101e0
Binary files /dev/null and b/ui/static/mstile-144x144.png differ
diff --git a/ui/utils/delay.js b/ui/utils/delay.js
new file mode 100644
index 000000000..d0ba18b03
--- /dev/null
+++ b/ui/utils/delay.js
@@ -0,0 +1,3 @@
+export default function (ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
diff --git a/ui/utils/emulateAnchorClick.js b/ui/utils/emulateAnchorClick.js
new file mode 100644
index 000000000..a4517d3f1
--- /dev/null
+++ b/ui/utils/emulateAnchorClick.js
@@ -0,0 +1,19 @@
+/**
+ *
+ * @param {Event} ev
+ * @param {String} targetUrl
+ * @param {Boolean} alwaysOpenInNewWindow
+ */
+export default function emulateAnchorClick(ev, targetUrl, alwaysOpenInNewWindow = false) {
+ let openInNewWindow;
+ if (alwaysOpenInNewWindow) {
+ openInNewWindow = true;
+ } else {
+ openInNewWindow = (ev.ctrlKey || ev.shiftKey || ev.metaKey);
+ }
+ if (openInNewWindow) {
+ window.open(targetUrl);
+ } else {
+ window.location.href = targetUrl;
+ }
+}
diff --git a/ui/utils/i18n.js b/ui/utils/i18n.js
new file mode 100644
index 000000000..1ce474d1f
--- /dev/null
+++ b/ui/utils/i18n.js
@@ -0,0 +1,8 @@
+import substitute from './substitute';
+
+export default function i18n(str, ...params) {
+ if (window.LOCALES && window.LOCALES[str]) {
+ return substitute(window.LOCALES[str], params);
+ }
+ return substitute(str, params);
+}
diff --git a/ui/utils/loadReactRedux.js b/ui/utils/loadReactRedux.js
new file mode 100644
index 000000000..c9cbe2e1f
--- /dev/null
+++ b/ui/utils/loadReactRedux.js
@@ -0,0 +1,30 @@
+export default async function loadReactRedux(storeReducer) {
+ const React = await import('react');
+ const { render, unmountComponentAtNode } = await import('react-dom');
+ const { Provider } = await import('react-redux');
+ const { createStore, applyMiddleware } = await import('redux');
+ const { default: reduxThunk } = await import('redux-thunk');
+ const { default: reduxPromise } = await import('redux-promise-middleware');
+
+ const reduxMiddlewares = [];
+ reduxMiddlewares.push(reduxThunk);
+ reduxMiddlewares.push(reduxPromise());
+
+ if (process.env.NODE_ENV !== 'production') {
+ const { createLogger } = await import('redux-logger');
+ reduxMiddlewares.push(createLogger({
+ collapsed: true,
+ duration: true,
+ }));
+ }
+
+ const store = createStore(storeReducer, applyMiddleware(...reduxMiddlewares));
+
+ return {
+ React,
+ render,
+ unmountComponentAtNode,
+ Provider,
+ store,
+ };
+}
diff --git a/ui/utils/mediaQuery.js b/ui/utils/mediaQuery.js
new file mode 100644
index 000000000..15dc97815
--- /dev/null
+++ b/ui/utils/mediaQuery.js
@@ -0,0 +1,13 @@
+export function isAbove(width) {
+ if (window.matchMedia) {
+ return window.matchMedia(`(min-width: ${width}px)`).matches;
+ }
+ return window.innerWidth >= width;
+}
+
+export function isBelow(width) {
+ if (window.matchMedia) {
+ return window.matchMedia(`(max-width: ${width}px)`).matches;
+ }
+ return window.innerWidth <= width;
+}
diff --git a/ui/utils/mongoId.js b/ui/utils/mongoId.js
new file mode 100644
index 000000000..1bfd8a212
--- /dev/null
+++ b/ui/utils/mongoId.js
@@ -0,0 +1,15 @@
+/* eslint-disable */
+
+// https://github.com/andrasq/node-mongoid-js/blob/master/mongoid.js
+
+export function parse(idstring) {
+ if (typeof idstring !== 'string') {
+ idstring = String(idstring);
+ }
+ return {
+ timestamp: parseInt(idstring.slice( 0, 0+8), 16),
+ machineid: parseInt(idstring.slice( 8, 8+6), 16),
+ pid: parseInt(idstring.slice(14, 14+4), 16),
+ sequence: parseInt(idstring.slice(18, 18+6), 16),
+ };
+}
diff --git a/ui/utils/parseQueryString.js b/ui/utils/parseQueryString.js
new file mode 100644
index 000000000..88a31960d
--- /dev/null
+++ b/ui/utils/parseQueryString.js
@@ -0,0 +1,11 @@
+export default function parseQueryString(str) {
+ const obj = {};
+ (str || document.location.search)
+ .replace(/(^\?)/, '')
+ .split('&')
+ .forEach((n) => {
+ const [key, value] = n.split('=').map(v => decodeURIComponent(v));
+ obj[key] = value;
+ });
+ return obj;
+}
diff --git a/ui/utils/pjax.js b/ui/utils/pjax.js
new file mode 100644
index 000000000..de647b024
--- /dev/null
+++ b/ui/utils/pjax.js
@@ -0,0 +1,150 @@
+// Modified from jquery-pjax to support multiple fragments and jQuery 3.0
+// https://github.com/defunkt/jquery-pjax/blob/master/jquery.pjax.js
+
+import uuid from 'uuid/v4';
+
+import Notification from 'vj/components/notification';
+import NProgress from 'vj/components/nprogress';
+import request from './request';
+
+const pjax = {};
+
+let currentState = null;
+let currentXHR = null;
+let inProgress = 0;
+
+function cancelLastXHR() {
+ if (currentXHR && currentXHR.readyState < 4) {
+ currentXHR.abort();
+ currentXHR = null;
+ }
+}
+
+function canonicalizeUrl(url) {
+ const a = document.createElement('a');
+ a.href = url;
+ return a.href;
+}
+
+function extractMetaData(requestOption, response) {
+ const metaData = {
+ title: response.title ? response.title : document.title,
+ url: response.url ? response.url : requestOption.url,
+ };
+ metaData.url = canonicalizeUrl(metaData.url);
+ return metaData;
+}
+
+function incProgress() {
+ inProgress++;
+ NProgress.done();
+ NProgress.start();
+}
+
+function decProgress() {
+ inProgress--;
+ if (inProgress === 0) {
+ NProgress.done();
+ }
+}
+
+pjax.request = async (opt) => {
+ const options = {
+ method: 'get',
+ push: true,
+ ...opt,
+ };
+ if (!currentState) {
+ // create initial state
+ currentState = {
+ id: uuid(),
+ options: {
+ url: window.location.href,
+ },
+ };
+ window.history.replaceState(currentState, null);
+ }
+ cancelLastXHR();
+ if (options.push) {
+ // in popstate, options.push === false
+ window.history.pushState(null, null, options.url);
+ }
+ incProgress();
+ try {
+ const data = await request.ajax({
+ beforeSend(xhr) {
+ currentXHR = xhr;
+ },
+ ...options,
+ });
+ const meta = extractMetaData(options, data);
+ currentState = {
+ id: uuid(),
+ options: opt,
+ };
+ if (options.push) {
+ window.history.replaceState(currentState, null, meta.url);
+ }
+ document.title = meta.title;
+ data.fragments.forEach((fragment) => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (fragment.html === undefined) {
+ // eslint-disable-next-line quotes
+ throw new Error(`Fragement should contain 'html'`);
+ }
+ }
+ const $el = $(fragment.html.trim());
+ if (process.env.NODE_ENV !== 'production') {
+ if ($el.length === 0) {
+ // eslint-disable-next-line quotes
+ throw new Error(`Unable to build elements from fragment 'html'`);
+ }
+ }
+ const fragmentId = $el.attr('data-fragment-id');
+ if (process.env.NODE_ENV !== 'production') {
+ if (!fragmentId) {
+ // eslint-disable-next-line quotes
+ throw new Error(`Unable to extract fragment id from fragment 'html'`);
+ }
+ }
+ const $target = $(`[data-fragment-id="${fragmentId}"]`);
+ if (process.env.NODE_ENV !== 'production') {
+ if ($target.length === 0) {
+ // eslint-disable-next-line quotes
+ throw new Error(`Unable to get target fragment from fragment id`);
+ }
+ }
+ $target.trigger('vjContentRemove');
+ $target.replaceWith($el);
+ $el.trigger('vjContentNew');
+ });
+ } catch (err) {
+ if (!err.aborted) {
+ Notification.error(err.message);
+ console.log(err);
+ }
+ } finally {
+ decProgress();
+ }
+};
+
+function handlePopState(ev) {
+ if (!ev.originalEvent) {
+ return;
+ }
+ const { state } = ev.originalEvent;
+ if (!state) {
+ return;
+ }
+ if (!state.id || (currentState && state.id === currentState.id)) {
+ return;
+ }
+ pjax.request({
+ ...state.options,
+ push: false,
+ });
+}
+
+$(window).on('popstate', handlePopState);
+
+export default pjax;
diff --git a/ui/utils/request.js b/ui/utils/request.js
new file mode 100644
index 000000000..8298dd3cf
--- /dev/null
+++ b/ui/utils/request.js
@@ -0,0 +1,79 @@
+import i18n from './i18n';
+
+const request = {
+ /**
+ * @param {object} options
+ */
+ async ajax(options) {
+ return new Promise((resolve, reject) => {
+ $
+ .ajax({
+ dataType: 'json',
+ ...options,
+ })
+ .fail((jqXHR, textStatus, errorThrown) => {
+ if (textStatus === 'abort') {
+ const err = new Error(i18n('Aborted'));
+ err.aborted = true;
+ reject(err);
+ } else if (jqXHR.readyState === 0) {
+ reject(new Error(i18n('Network error')));
+ } else if (errorThrown instanceof Error) {
+ reject(errorThrown);
+ } else if (typeof jqXHR.responseJSON === 'object' && jqXHR.responseJSON.error) {
+ reject(new Error(jqXHR.responseJSON.error.message));
+ } else {
+ reject(new Error(textStatus));
+ }
+ })
+ .done(resolve);
+ });
+ },
+
+ /**
+ * @param {string} url
+ * @param {JQueryStatic | Node | string | object} dataOrForm
+ * @param {object} options
+ */
+ post(url, dataOrForm = {}, options = {}) {
+ let postData;
+ if (dataOrForm instanceof jQuery && dataOrForm.is('form')) {
+ // $form
+ postData = dataOrForm.serialize();
+ } else if (dataOrForm instanceof Node && $(dataOrForm).is('form')) {
+ // form
+ postData = $(dataOrForm).serialize();
+ } else if (typeof dataOrForm === 'string') {
+ // foo=bar&box=boz
+ postData = dataOrForm;
+ } else {
+ // {foo: 'bar'}
+ postData = $.param({
+ csrf_token: UiContext.csrf_token,
+ ...dataOrForm,
+ }, true);
+ }
+ return request.ajax({
+ url,
+ method: 'post',
+ data: postData,
+ ...options,
+ });
+ },
+
+ /**
+ * @param {string} url
+ * @param {object} qs
+ * @param {object} options
+ */
+ get(url, qs = {}, options = {}) {
+ return request.ajax({
+ url,
+ data: qs,
+ method: 'get',
+ ...options,
+ });
+ },
+};
+
+export default request;
diff --git a/ui/utils/slide.js b/ui/utils/slide.js
new file mode 100644
index 000000000..92bf366b4
--- /dev/null
+++ b/ui/utils/slide.js
@@ -0,0 +1,57 @@
+import 'jquery.easing';
+
+export async function slideDown($element, duration, fromCss = {}, toCss = {}) {
+ const originalStyl = $element.attr('style') || '';
+ $element.css({
+ position: 'absolute',
+ visibility: 'none',
+ display: 'block',
+ });
+ const height = $element.outerHeight();
+ $element.attr('style', originalStyl);
+ $element.css({
+ height: 0,
+ overflow: 'hidden',
+ display: 'block',
+ ...fromCss,
+ });
+ $element.height();
+ await $element
+ .transition({
+ height,
+ ...toCss,
+ }, {
+ duration,
+ easing: 'easeOutCubic',
+ })
+ .promise();
+ $element.attr('style', originalStyl);
+ $element.css({
+ display: 'block',
+ });
+}
+
+export async function slideUp($element, duration, fromCss = {}, toCss = {}) {
+ const originalStyl = $element.attr('style') || '';
+ const height = $element.outerHeight();
+ $element.css({
+ height,
+ overflow: 'hidden',
+ display: 'block',
+ ...fromCss,
+ });
+ $element.height();
+ await $element
+ .transition({
+ height: 0,
+ ...toCss,
+ }, {
+ duration,
+ easing: 'easeOutCubic',
+ })
+ .promise();
+ $element.attr('style', originalStyl);
+ $element.css({
+ display: 'none',
+ });
+}
diff --git a/ui/utils/substitute.js b/ui/utils/substitute.js
new file mode 100644
index 000000000..2e2637691
--- /dev/null
+++ b/ui/utils/substitute.js
@@ -0,0 +1,8 @@
+export default function substitute(str, obj) {
+ return str.replace(/\{([^{}]+)\}/g, (match, key) => {
+ if (obj[key] !== undefined) {
+ return String(obj[key]);
+ }
+ return `{${key}}`;
+ });
+}
diff --git a/ui/utils/tpl.js b/ui/utils/tpl.js
new file mode 100644
index 000000000..41ab6ced5
--- /dev/null
+++ b/ui/utils/tpl.js
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+
+export default function tpl(pieces, ...substitutions) {
+ let result = pieces[0];
+ for (let i = 0; i < substitutions.length; ++i) {
+ const subst = substitutions[i];
+ let substHtml;
+ if (typeof subst === 'object' && subst.templateRaw) {
+ substHtml = subst.html;
+ } else {
+ substHtml = _.escape(String(subst));
+ }
+ result += substHtml + pieces[i + 1];
+ }
+ return result;
+}
+
+export function rawHtml(html) {
+ return {
+ templateRaw: true,
+ html,
+ };
+}
diff --git a/ui/utils/zIndexManager.js b/ui/utils/zIndexManager.js
new file mode 100644
index 000000000..7580f6c73
--- /dev/null
+++ b/ui/utils/zIndexManager.js
@@ -0,0 +1,12 @@
+let zIndexCurrent = 1000;
+
+const manager = {
+ getCurrent() {
+ return zIndexCurrent;
+ },
+ getNext() {
+ return ++zIndexCurrent;
+ },
+};
+
+export default manager;