You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
364 lines
14 KiB
JavaScript
364 lines
14 KiB
JavaScript
import _ from 'lodash';
|
|
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 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 = [];
|
|
const selections = [];
|
|
const tags = [];
|
|
|
|
function setDomSelected($dom, selected) {
|
|
if (selected) $dom.addClass('selected');
|
|
else $dom.removeClass('selected');
|
|
}
|
|
|
|
function onBeforeUnload(e) {
|
|
e.returnValue = '';
|
|
}
|
|
|
|
async function updateSelection() {
|
|
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 = subcategory;
|
|
if (item.select) selections.push(selectionName);
|
|
else _.pull(selections, selectionName);
|
|
}
|
|
});
|
|
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));
|
|
dirtyCategories.length = 0;
|
|
const $txt = $('[name="tag"]');
|
|
$txt.val([...requestTags, ...tags].join(', '));
|
|
}
|
|
|
|
function findCategory(name) {
|
|
const keys = Object.keys(categories);
|
|
if (keys.includes(name)) return [name, null];
|
|
for (const category of keys) {
|
|
const subkeys = Object.keys(categories[category].children);
|
|
if (subkeys.includes(name)) return [category, name];
|
|
}
|
|
return [null, null];
|
|
}
|
|
|
|
function parseCategorySelection() {
|
|
const $txt = $('[name="tag"]');
|
|
tags.length = 0;
|
|
$txt.val().split(',').map((name) => name.trim()).forEach((name) => {
|
|
if (!name) return;
|
|
const [category, subcategory] = findCategory(name);
|
|
if (!category) tags.push(name);
|
|
else 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();
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
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();
|
|
});
|
|
}
|
|
|
|
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) => {
|
|
ev.preventDefault();
|
|
if (confirmed) {
|
|
return request.post('.', { operation: 'delete' }).then((res) => {
|
|
window.location.href = res.url;
|
|
}).catch((e) => {
|
|
Notification.error(e.message);
|
|
});
|
|
}
|
|
const message = 'Confirm deleting this problem? Its files, submissions, discussions and solutions will be deleted as well.';
|
|
return new ConfirmDialog({
|
|
$body: tpl`
|
|
<div class="typo">
|
|
<p>${i18n(message)}</p>
|
|
</div>`,
|
|
}).open().then((action) => {
|
|
if (action !== 'yes') return;
|
|
confirmed = true;
|
|
ev.target.click();
|
|
});
|
|
});
|
|
$(document).on('change', '[name="tag"]', parseCategorySelection);
|
|
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: 'additional_file',
|
|
});
|
|
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://')) {
|
|
$(this).attr('src', $(this).attr('src').replace('file://', (pagename === 'problem_create' ? `/file/${UserContext._id}/` : './file/')));
|
|
}
|
|
});
|
|
}, 500);
|
|
|
|
const $main = $('textarea[data-editor]');
|
|
const $field = $('textarea[data-markdown-upload]');
|
|
let content = $field.val();
|
|
let isObject = false;
|
|
let activeTab = $('[data-lang]').first().attr('data-lang');
|
|
try {
|
|
content = JSON.parse(content);
|
|
isObject = !(content instanceof Array);
|
|
if (!isObject) content = JSON.stringify(content);
|
|
} catch (e) { }
|
|
if (!isObject) content = { [activeTab]: content };
|
|
function getContent(lang) {
|
|
let c = '';
|
|
if (content[lang]) c = content[lang];
|
|
else {
|
|
const list = Object.keys(content).filter((l) => l.startsWith(lang));
|
|
if (list.length) c = content[list[0]];
|
|
}
|
|
if (typeof c !== 'string') c = JSON.stringify(c);
|
|
return c;
|
|
}
|
|
$main.val(getContent(activeTab));
|
|
function onChange(val) {
|
|
try {
|
|
val = JSON.parse(val);
|
|
if (!(val instanceof Array)) val = JSON.stringify(val);
|
|
} catch { }
|
|
const empty = /^\s*$/g.test(val);
|
|
if (empty) delete content[activeTab];
|
|
else content[activeTab] = val;
|
|
if (!Object.keys(content).length) $field.text('');
|
|
else $field.text(JSON.stringify(content));
|
|
}
|
|
const editor = Editor.getOrConstruct($main, { onChange });
|
|
$('[data-lang]').on('click', (ev) => {
|
|
$('[data-lang]').removeClass('tab--active');
|
|
$(ev.currentTarget).addClass('tab--active');
|
|
const lang = $(ev.currentTarget).attr('data-lang');
|
|
activeTab = lang;
|
|
const val = getContent(lang);
|
|
editor.value(val);
|
|
});
|
|
$('[type="submit"]').on('click', (ev) => {
|
|
if (!$('[name="title"]').val().toString().length) {
|
|
Notification.error(i18n('Title is required.'));
|
|
$('body').scrollTop();
|
|
$('html, body').animate(
|
|
{ scrollTop: 0 },
|
|
300,
|
|
() => $('[name="title"]').focus(),
|
|
);
|
|
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'));
|
|
});
|