ui: change to ts & tsx

This commit is contained in:
undefined committed 2021-10-11 23:33:54 +08:00
1 parent 36cce6d1bd
commit d10fec361c
29 files changed
+165 -221

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "2.39.18",
"version": "2.39.19",
"bin": "bin/hydrooj.js",
"main": "src/loader",
"module": "src/loader",
+1 -1
View File
@@ -47,7 +47,7 @@ export const ForbiddenError = Err('ForbiddenError', UserFacingError, 'ForbiddenE
export const NotFoundError = Err('NotFoundError', UserFacingError, 'NotFoundError', 404);
export const MethodNotAllowedError = Err('MethodNotAllowedError', UserFacingError, 'MethodNotAllowedError', 405);
export const RemoteOnlineJudgeError = Err('RemoteOnlineJudgeError', UserFacingError, 'RemoteOnlineJudgeError', 500);
export const SendMailError = Err('SendMailError', UserFacingError, 'Failed to send mail to {0}.', 500);
export const SendMailError = Err('SendMailError', UserFacingError, 'Failed to send mail to {0}. (1)', 500);
export const AlreadyVotedError = Err('AlreadyVotedError', ForbiddenError, "You've already voted.");
export const LoginError = Err('LoginError', ForbiddenError, 'Invalid password for user {0}.');
-15
View File
@@ -49,11 +49,6 @@ function set(key: string, value: any) {
class SystemHandler extends Handler {
async prepare() {
this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
this.response.body = {
path: [
['Hydro', 'homepage'],
],
};
}
}
@@ -86,12 +81,6 @@ class SystemCheckConnHandler extends ConnectionHandler {
class SystemDashboardHandler extends SystemHandler {
async get() {
this.response.template = 'manage_dashboard.html';
this.response.body.path.push(['manage_dashboard', null]);
}
async postRestart() {
// TODO handle restart
this.back();
}
}
@@ -99,7 +88,6 @@ class SystemScriptHandler extends SystemHandler {
async get() {
this.response.template = 'manage_script.html';
this.response.body.scripts = global.Hydro.script;
this.response.body.path.push(['manage_script', null]);
}
@param('id', Types.Name)
@@ -147,7 +135,6 @@ class SystemScriptHandler extends SystemHandler {
class SystemSettingHandler extends SystemHandler {
async get() {
this.response.template = 'manage_setting.html';
this.response.body.path.push(['manage_setting', null]);
this.response.body.current = {};
this.response.body.settings = setting.SYSTEM_SETTINGS;
for (const s of this.response.body.settings) {
@@ -185,7 +172,6 @@ class SystemSettingHandler extends SystemHandler {
class SystemUserImportHandler extends SystemHandler {
async get() {
this.response.body.users = [];
this.response.body.path.push(['manage_user_import']);
this.response.template = 'manage_user_import.html';
}
@@ -232,7 +218,6 @@ class SystemUserImportHandler extends SystemHandler {
}
}
}
this.response.body.path.push(['manage_user_import']);
this.response.body.users = udocs;
this.response.body.messages = messages;
}
-30
View File
@@ -13,35 +13,6 @@ import {
} from '../service/server';
import { sortFiles } from '../utils';
class CheckInHandler extends Handler {
async prepare() {
const { checkincnt, lastcheckin } = this.user;
const today = new Date();
today.setHours(0);
today.setMinutes(0);
today.setSeconds(0);
today.setMilliseconds(0);
if (lastcheckin === today.getTime()) throw new BadRequestError("You've already checked in today!");
this.user.checkincnt++;
this.user.lastcheckin = today.getTime();
await Promise.all([
user.setById(this.user._id, { checkincnt, lastcheckin }),
user.inc(this.user._id, 'rpdelta', Math.round(Math.sqrt(checkincnt))),
]);
}
async get() {
this.response.redirect = '/';
}
async post() {
this.response.body = {
cnt: this.user.checkincnt,
last: this.user.lastcheckin,
};
}
}
class SwitchLanguageHandler extends Handler {
@param('lang', Types.Name)
async get(domainId: string, lang: string) {
@@ -132,7 +103,6 @@ export class FSDownloadHandler extends Handler {
}
export async function apply() {
Route('check_in', '/checkin', CheckInHandler, PRIV.PRIV_USER_PROFILE);
Route('switch_language', '/language/:lang', SwitchLanguageHandler);
Route('home_files', '/file', FilesHandler, PRIV.PRIV_CREATE_FILE);
Route('fs_download', '/file/:uid/:filename', FSDownloadHandler);
+2
View File
@@ -677,6 +677,8 @@ declare global {
addons: string[],
}
}
var Hydro: HydroGlobal; // eslint-disable-line
var addons: string[]; // eslint-disable-line
}
declare module 'koa' {
+1 -1
View File
@@ -33,7 +33,7 @@ export async function sendMail(to: string, subject: string, text: string, html:
});
} catch (e) {
logger.error(e);
throw new SendMailError(to);
throw new SendMailError(to, e.message);
}
return t;
}
-2
View File
@@ -131,8 +131,6 @@ AccountSetting(
'/components/profile/backgrounds/1.jpg', 'text', 'Profile Background Image',
'Choose the background image in your profile page.'),
Setting('setting_storage', 'unreadMsg', 0, 'number', 'Unread Message Count', null, FLAG_DISABLED | FLAG_HIDDEN),
Setting('setting_storage', 'checkincnt', 0, 'number', 'Check In Counter', null, FLAG_DISABLED | FLAG_HIDDEN),
Setting('setting_storage', 'lastcheckin', 0, 'number', 'Last checkin time', null, FLAG_DISABLED | FLAG_HIDDEN),
Setting('setting_storage', 'badge', '', 'text', 'badge info', null, FLAG_DISABLED | FLAG_HIDDEN),
);
+11 -3
View File
@@ -62,6 +62,16 @@ module.exports = {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/lines-between-class-members': [
'error',
'always',
{ exceptAfterSingleLine: true },
],
'lines-between-class-members': [
'error',
'always',
{ exceptAfterSingleLine: true },
],
'comma-dangle': [
'error',
'always-multiline',
@@ -69,9 +79,7 @@ module.exports = {
indent: [
'error',
2,
{
SwitchCase: 0,
},
{ SwitchCase: 0 },
],
'max-len': ['error', 150],
quotes: 'warn',
@@ -2,20 +2,33 @@ import _ from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import zIndexManager from 'vj/utils/zIndexManager';
export interface DialogOptions {
classes: string;
$body: HTMLElement;
$action: any;
width?: string;
height?: string;
cancelByClickingBack?: boolean;
cancelByEsc?: boolean;
canCancel?: boolean;
onDispatch?: (data: any) => any;
}
export default class DomDialog extends DOMAttachedObject {
static DOMAttachKey = 'vjDomDialogInstance';
isShown = false;
isAnimating = false;
options: DialogOptions;
_resolve: (value: string | PromiseLike<string>) => void;
constructor($dom, options = {}) {
constructor($dom, options: Partial<DialogOptions> = {}) {
super($dom);
this.isShown = false;
this.isAnimating = false;
this.options = {
cancelByClickingBack: false,
cancelByEsc: false,
onDispatch: () => { },
...options,
...options as any,
};
this._defer = null;
}
async _show() {
@@ -70,9 +83,7 @@ export default class DomDialog extends DOMAttachedObject {
$(document).off(`keyup.${this.eventNS}`);
this.$dom.off(`click.${this.eventNS}`);
this.$dom.css({
opacity: 1,
});
this.$dom.css({ opacity: 1 });
this.$dom.transition({
opacity: 0,
}, {
@@ -80,17 +91,15 @@ export default class DomDialog extends DOMAttachedObject {
});
const $dgContent = this.$dom.find('.dialog__content');
$dgContent.css({
scale: 1,
});
$dgContent.css({ scale: 1 });
await $dgContent
.transition({
scale: 0.8,
}, {
duration: 200,
easing: 'easeOutCubic',
})
.promise();
.transition(
{ scale: 0.8 },
{
duration: 200,
easing: 'easeOutCubic',
},
).promise();
this.$dom.css('display', 'none');
@@ -103,28 +112,23 @@ export default class DomDialog extends DOMAttachedObject {
if (this.isShown || this.isAnimating) {
return Promise.reject();
}
this._defer = new $.Deferred();
const promise = new Promise<string>((resolve) => {
this._resolve = resolve;
});
this._show();
return this._defer.promise();
return promise;
}
hide() {
if (!this.isShown || this.isAnimating) {
return false;
}
if (this._defer.state() === 'pending') {
this._defer.resolve('cancel');
}
this._defer = null;
if (!this.isShown || this.isAnimating) return false;
this._resolve('cancel');
this._hide();
return true;
}
dispatchAction(data) {
if (this.options.onDispatch(data) === false) {
return;
}
this._defer.resolve(data);
dispatchAction(data: string) {
if (this.options.onDispatch(data) === false) return;
this._resolve(data);
this.hide();
}
@@ -1,9 +1,13 @@
import tpl from 'vj/utils/tpl';
import i18n from 'vj/utils/i18n';
import DomDialog from './DomDialog';
import DomDialog, { DialogOptions } from './DomDialog';
export class Dialog {
constructor(options = {}) {
options: DialogOptions;
$dom: JQuery<any>;
domDialogInstance: DomDialog;
constructor(options: Partial<DialogOptions> = {}) {
this.options = {
classes: '',
$body: null,
@@ -60,7 +64,7 @@ const buttonYes = tpl`<button class="primary rounded button" data-action="yes">$
const buttonNo = tpl`<button class="rounded button" data-action="no">${i18n('No')}</button>`;
export class InfoDialog extends Dialog {
constructor(options = {}) {
constructor(options: Partial<DialogOptions> = {}) {
super({
$action: buttonOk,
cancelByClickingBack: true,
@@ -71,7 +75,7 @@ export class InfoDialog extends Dialog {
}
export class ActionDialog extends Dialog {
constructor(options = {}) {
constructor(options: Partial<DialogOptions> = {}) {
super({
$action: [buttonCancel, buttonOk].join('\n'),
cancelByClickingBack: true,
@@ -82,7 +86,7 @@ export class ActionDialog extends Dialog {
}
export class ConfirmDialog extends Dialog {
constructor(options = {}) {
constructor(options: Partial<DialogOptions> = {}) {
let buttons = [];
if (options.canCancel) {
buttons = [buttonCancel, buttonNo, buttonYes];
@@ -5,6 +5,7 @@ import pipeStream from 'vj/utils/pipeStream';
import i18n from 'vj/utils/i18n';
import request from 'vj/utils/request';
import Notification from 'vj/components/notification';
import api from 'vj/utils/api';
let isBeforeUnloadTriggeredByLibrary = !window.isSecureContext;
function onBeforeUnload(e) {
@@ -67,7 +68,24 @@ export async function downloadProblemSet(pids, name = 'Export') {
const targets = [];
try {
for (const pid of pids) {
const { pdoc } = await request.get(`/d/${UiContext.domainId}/p/${pid}`);
const { pdoc } = await request.get(`/d/${UiContext.domainId}/api`);
await api(gql`
problem(id: ${pid}) {
pid
owner
title
content
tag
nSubmit
nAccept
data {
name
}
additional_file {
name
}
}
`);
targets.push({
filename: `${pid}/problem.yaml`,
content: dump({
@@ -77,7 +95,6 @@ export async function downloadProblemSet(pids, name = 'Export') {
tag: pdoc.tag,
nSubmit: pdoc.nSubmit,
nAccept: pdoc.nAccept,
difficulty: pdoc.difficulty,
}),
});
targets.push({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.24.5",
"version": "4.25.0",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.js",
@@ -1,9 +1,8 @@
import { NamedPage } from 'vj/misc/Page';
const page = new NamedPage('contest_main', () => {
// Contest Filter
$('[name="filter-form"] [name="rule"]').change(() => {
$('[name="filter-form"]').submit();
$('[name="filter-form"] [name="rule"]').on('change', () => {
$('[name="filter-form"]').trigger('submit');
});
});
@@ -1,10 +0,0 @@
import { NamedPage } from 'vj/misc/Page';
const page = new NamedPage('discussion_detail', async () => {
$(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
$(ev.currentTarget).hide();
$('[name="problem-sidebar__categories"]').show();
});
});
export default page;
@@ -1,10 +0,0 @@
import { NamedPage } from 'vj/misc/Page';
const page = new NamedPage('discussion_node', async () => {
$(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
$(ev.currentTarget).hide();
$('[name="problem-sidebar__categories"]').show();
});
});
export default page;
@@ -6,13 +6,13 @@ const page = new NamedPage('domain_join_applications', () => {
const $expire = $('[name="expire"]');
const $code = $('[name="invitationCode"]');
function updateFormState() {
const method = parseInt($('[name="method"]').val(), 10);
const method = +$('[name="method"]').val();
$role.prop('disabled', method === domainEnum.JOIN_METHOD_NONE).trigger('vjFormDisableUpdate');
$expire.prop('disabled', method === domainEnum.JOIN_METHOD_NONE).trigger('vjFormDisableUpdate');
$code.prop('disabled', method !== domainEnum.JOIN_METHOD_CODE).trigger('vjFormDisableUpdate');
}
updateFormState();
$('[name="method"]').change(() => updateFormState());
$('[name="method"]').on('change', updateFormState);
});
export default page;
@@ -40,9 +40,7 @@ const page = new NamedPage('domain_role', () => {
async function handleClickCreateRole() {
const action = await createRoleDialog.clear().open();
if (action !== 'ok') {
return;
}
if (action !== 'ok') return;
const role = createRoleDialog.$dom.find('[name="role"]').val();
try {
await request.post('', {
@@ -1,9 +1,6 @@
import yaml from 'js-yaml';
import { getScoreColor } from '@hydrooj/utils/lib/status';
import { NamedPage } from 'vj/misc/Page';
import tpl from 'vj/utils/tpl';
import i18n from 'vj/utils/i18n';
import { ConfirmDialog } from 'vj/components/dialog';
import { downloadProblemSet } from 'vj/components/zipDownloader';
import loadReactRedux from 'vj/utils/loadReactRedux';
import delay from 'vj/utils/delay';
@@ -263,15 +260,7 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor
leaveScratchpadMode();
ev.preventDefault();
});
$(document).on('click', '[name="problem-sidebar__rejudge"]', (ev) => {
ev.preventDefault();
new ConfirmDialog({
$body: tpl.typoMsg(i18n('Confirm rejudge this problem?')),
}).open().then((action) => {
if (action !== 'yes') return;
$(ev.currentTarget).closest('form').trigger('submit');
});
});
$(document).on('click', '[name="show_tags"]', (ev) => {
$(ev.currentTarget).hide();
$('span.tags').css('display', 'inline-block');
@@ -144,10 +144,6 @@ function buildCategoryFilter() {
}
export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
$(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
$(ev.currentTarget).hide();
$('[name="problem-sidebar__categories"]').show();
});
let confirmed = false;
$(document).on('click', '[name="operation"]', (ev) => {
ev.preventDefault();
@@ -178,7 +174,7 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
$('img').each(function () {
if (this.src.startsWith('file://')) this.setAttribute('src', this.src.replace('file://', './file/'));
});
}, 50);
}, 200);
const $main = $('textarea[data-editor]');
const $field = $('textarea[data-markdown-upload]');
@@ -235,16 +235,6 @@ const page = new NamedPage('problem_files', () => {
await pjax.request({ push: false });
}
$(document).on('click', '[name="problem-sidebar__rejudge"]', (ev) => {
ev.preventDefault();
new ConfirmDialog({
$body: tpl.typoMsg(i18n('Confirm rejudge this problem?')),
}).open().then((action) => {
if (action !== 'yes') return;
$(ev.currentTarget).closest('form').trigger('submit');
});
});
if ($('[name="upload_testdata"]').length) {
$(document).on('click', '.problem-files-testdata .col--name', (ev) => handleEdit('testdata', ev));
$(document).on('click', '.problem-files-additional_file .col--name', (ev) => handleEdit('additional_file', ev));
@@ -0,0 +1,25 @@
import { NamedPage } from 'vj/misc/Page';
import tpl from 'vj/utils/tpl';
import i18n from 'vj/utils/i18n';
import { ConfirmDialog } from 'vj/components/dialog';
const page = new NamedPage([
'problem_create', 'problem_edit', 'problem_solution', 'problem_submit',
'problem_files', 'discussion_node', 'discussion_detail',
], () => {
$(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
$(ev.currentTarget).hide();
$('[name="problem-sidebar__categories"]').show();
});
$(document).on('click', '[name="problem-sidebar__rejudge"]', (ev) => {
ev.preventDefault();
new ConfirmDialog({
$body: tpl.typoMsg(i18n('Confirm rejudge this problem?')),
}).open().then((action) => {
if (action !== 'yes') return;
$(ev.currentTarget).closest('form').trigger('submit');
});
});
});
export default page;
@@ -1,10 +0,0 @@
import { NamedPage } from 'vj/misc/Page';
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;
@@ -8,11 +8,6 @@ function setOptions($el, options) {
}
const page = new NamedPage(['problem_submit', 'contest_detail_problem_submit', 'homework_detail_problem_submit'], async () => {
$(document).on('click', '[name="problem-sidebar__show-category"]', (ev) => {
$(ev.currentTarget).hide();
$('[name="problem-sidebar__categories"]').show();
});
const { domainId } = UiContext.pdocConfig;
function onChangeMain(update = true) {
@@ -1,8 +1,10 @@
import { NamedPage } from 'vj/misc/Page';
const page = new NamedPage('record_detail', async () => {
const { default: SockJs } = await import('../components/socket');
const { DiffDOM } = await import('diff-dom');
const [{ default: SockJs }, { DiffDOM }] = await Promise.all([
import('../components/socket'),
import('diff-dom'),
]);
const sock = new SockJs(UiContext.socketUrl);
const dd = new DiffDOM();
@@ -3,14 +3,18 @@ import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoCom
import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete';
const page = new NamedPage('record_main', async () => {
const { default: SockJs } = await import('../components/socket');
const { DiffDOM } = await import('diff-dom');
const [{ default: SockJs }, { DiffDOM }] = await Promise.all([
import('../components/socket'),
import('diff-dom'),
]);
const sock = new SockJs(UiContext.socketUrl);
const dd = new DiffDOM();
let firstLoad = true;
sock.onopen = () => {
sock.send(JSON.stringify({ rids: UiContext.rids }));
if (firstLoad) sock.send(JSON.stringify({ rids: UiContext.rids }));
firstLoad = false;
};
sock.onmessage = (message) => {
const msg = JSON.parse(message.data);
@@ -1,35 +0,0 @@
import { NamedPage } from 'vj/misc/Page';
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;
@@ -0,0 +1,24 @@
import { NamedPage } from 'vj/misc/Page';
import { slideDown, slideUp } from 'vj/utils/slide';
async function handleSection(ev, type: string) {
const $section = $(ev.currentTarget).closest('.training__section');
if ($section.is(`.${type}d, .animating`)) return;
$section.addClass('animating');
const $detail = $section.find('.training__section__detail');
if (type === 'expand') {
await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 });
} else {
await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 });
}
$section.addClass(`${type}d`);
$section.removeClass(type === 'expand' ? 'collapsed' : 'expanded');
$section.removeClass('animating');
}
const page = new NamedPage('training_detail', () => {
$(document).on('click', '[name="training__section__expand"]', (ev) => handleSection(ev, 'expand'));
$(document).on('click', '[name="training__section__collapse"]', (ev) => handleSection(ev, 'collapse'));
});
export default page;
@@ -15,10 +15,7 @@ export default new NamedPage('training_edit', () => {
}
const message = 'Confirm deleting this training? Its status will be deleted as well.';
return new ConfirmDialog({
$body: tpl`
<div class="typo">
<p>${i18n(message)}</p>
</div>`,
$body: tpl.typoMsg(i18n(message)),
}).open().then((action) => {
if (action !== 'yes') return;
confirmed = true;
+16 -14
View File
@@ -33,11 +33,11 @@
<div class="section__body">
<a href="{{ url('contest_detail', tid=tdoc.docId) }}" class="detail-button">{{ _('View Details') }}</a>
<div class="typo text-center">
{% if tsdict[tdoc.docId].attend %}
<p><span class="icon icon-check"></span> {{ _('Attended') }}</p>
{% else %}
<p><span class="icon icon-schedule"></span> {{ _('Not Attended') }}</p>
{% endif %}
{% if tsdict[tdoc.docId].attend %}
<p><span class="icon icon-check"></span> {{ _('Attended') }}</p>
{% else %}
<p><span class="icon icon-schedule"></span> {{ _('Not Attended') }}</p>
{% endif %}
</div>
</div>
</div>
@@ -48,23 +48,25 @@
<div class="row">
<div class="medium-9 columns">
{%- for tdoc in tdocs -%}
{% if model.contest.isOngoing(tdoc) %}
{{ render_important_contest('live', 'Live...', tdoc) }}
{% elif model.contest.isUpcoming(tdoc) %}
{{ render_important_contest('ready', 'Ready (☆▽☆)', tdoc) }}
{% endif %}
{% if model.contest.isOngoing(tdoc) %}
{{ render_important_contest('live', 'Live...', tdoc) }}
{% elif model.contest.isUpcoming(tdoc) %}
{{ render_important_contest('ready', 'Ready (☆▽☆)', tdoc) }}
{% endif %}
{%- endfor -%}
<div class="section">
<div class="section__header">
<h1 class="section__title">{% if not rule %}{{ _('All Contests') }}{% else %}{{ _('All {0} Contests').format(_(model.contest.RULES[rule].TEXT)) }}{% endif %}</h1>
<h1 class="section__title">{{ _('All Contests') if not rule else _('All {0} Contests').format(model.contest.RULES[rule].TEXT) }}</h1>
<div class="section__tools">
<form method="get" name="filter-form">
{{ _('Show') }}:
<select class="inline compact select" name="rule">
<option value="0"{% if not rule %} selected{% endif %}>{{ _('All') }}</option>
{%- for k, v in model.contest.RULES -%}
<option value="{{ k }}"{% if rule == k %} selected{% endif %}>{{ _('All {0} Contests').format(_(v.TEXT)) }}</option>
{%- endfor -%}
{%- for k, v in model.contest.RULES -%}
{% if k != 'homework' %}
<option value="{{ k }}"{% if rule == k %} selected{% endif %}>{{ _('All {0} Contests').format(v.TEXT) }}</option>
{% endif %}
{%- endfor -%}
</select>
<button type="submit" class="button hasjs--hide">
{{ _('Search') }}