From f5ef8b7f385bd486097924ecfca9a5a7221c3fb4 Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 17 Oct 2025 17:24:38 +0800 Subject: [PATCH] core&ui: add contest private files (#1053) Co-authored-by: panda --- framework/framework/base.ts | 8 +- framework/framework/server.ts | 2 +- packages/hydrooj/locales/ko.yaml | 1 - packages/hydrooj/locales/zh.yaml | 12 +- packages/hydrooj/src/handler/contest.ts | 161 +++++--- packages/hydrooj/src/handler/homework.ts | 8 +- packages/hydrooj/src/handler/problem.ts | 25 +- packages/hydrooj/src/interface.ts | 1 + packages/hydrooj/src/model/storage.ts | 4 +- packages/hydrooj/src/upgrade.ts | 8 + packages/ui-default/components/upload.tsx | 7 +- packages/ui-default/locales/ko.yaml | 1 - packages/ui-default/locales/zh.yaml | 8 +- packages/ui-default/pages/contest.page.styl | 2 + .../pages/contest_clarification.page.tsx | 25 ++ .../ui-default/pages/contest_manage.page.ts | 18 - packages/ui-default/pages/files.page.js | 102 ----- packages/ui-default/pages/files.page.styl | 19 +- packages/ui-default/pages/files.page.tsx | 348 ++++++++++++++++++ .../ui-default/pages/problem_config.page.styl | 10 - .../ui-default/pages/problem_config.page.tsx | 4 +- .../ui-default/pages/problem_edit.page.js | 100 +---- .../ui-default/pages/problem_edit.page.styl | 10 - .../ui-default/pages/problem_files.page.styl | 17 - .../ui-default/pages/problem_files.page.tsx | 347 +---------------- .../templates/contest_clarification.html | 103 ++++++ .../ui-default/templates/contest_detail.html | 3 +- .../ui-default/templates/contest_edit.html | 14 + .../ui-default/templates/contest_manage.html | 163 +++----- .../templates/contest_problemlist.html | 20 +- .../partials/contest_sidebar_management.html | 3 + .../ui-default/templates/partials/files.html | 21 +- .../templates/partials/problem_files.html | 8 +- .../ui-default/templates/problem_edit.html | 17 +- .../ui-default/templates/problem_files.html | 24 +- 35 files changed, 781 insertions(+), 843 deletions(-) create mode 100644 packages/ui-default/pages/contest_clarification.page.tsx delete mode 100644 packages/ui-default/pages/files.page.js create mode 100644 packages/ui-default/pages/files.page.tsx delete mode 100644 packages/ui-default/pages/problem_edit.page.styl delete mode 100644 packages/ui-default/pages/problem_files.page.styl create mode 100644 packages/ui-default/templates/contest_clarification.html diff --git a/framework/framework/base.ts b/framework/framework/base.ts index 1f46f98bb..151f63c1a 100644 --- a/framework/framework/base.ts +++ b/framework/framework/base.ts @@ -64,8 +64,12 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => { } if (!response.type) { if (response.pjax && args.pjax) { - const html = await handler.renderHTML(response.pjax, response.body); - response.body = { fragments: [{ html }] }; + const pjax = typeof response.pjax === 'string' ? [[response.pjax, {}]] : response.pjax; + response.body = { + fragments: (await Promise.all( + pjax.map(async ([template, extra]) => handler.renderHTML(template, { ...response.body, ...extra })), + )).map((i) => ({ html: i })), + }; response.type = 'application/json'; } else if ( request.json || response.redirect diff --git a/framework/framework/server.ts b/framework/framework/server.ts index 1d2500ccb..8fea33d18 100644 --- a/framework/framework/server.ts +++ b/framework/framework/server.ts @@ -82,7 +82,7 @@ export interface HydroResponse { * If set, and pjax content was request from client, * The template will be used for rendering. */ - pjax?: string; + pjax?: string | (readonly [string, Record])[]; redirect?: string; disposition?: string; etag?: string; diff --git a/packages/hydrooj/locales/ko.yaml b/packages/hydrooj/locales/ko.yaml index 2193be48a..a48e40cfb 100644 --- a/packages/hydrooj/locales/ko.yaml +++ b/packages/hydrooj/locales/ko.yaml @@ -428,7 +428,6 @@ name: 이름 Name: 이름 Network error: Network error Never expire: 영원히 만료되지 않음 -New dataset: 새 데이터셋 New Email: 새 이메일 New file: 새 파일 New Password: 새 비밀번호 diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 5c50ad7f6..f62a33105 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -153,13 +153,14 @@ Contact Us: 联系我们 Contact: 联系 content: 内容 Content: 内容 -Contest {0} has a new clarification about {1}, please go to contest management to reply.: 比赛 {0} 有一条关于 {1} 的新问答,请前往比赛管理页面回复。 -Contest {0} jury replied to your clarification, please go to contest page to view.: 比赛 {0} 的裁判回复了您的问答,请前往比赛页面查看。 -Contest Clarifications: 问答 +Contest {0} has a new clarification about {1}, please go to contest clarifications page to reply.: 比赛 {0} 有一条关于 {1} 的新答疑,请前往比赛答疑页面回复。 +Contest {0} jury replied to your clarification, please go to contest page to view.: 比赛 {0} 的裁判回复了您的答疑,请前往比赛页面查看。 +Contest Clarifications: 赛时答疑 Contest Management: 比赛管理 Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。 Contest Scoreboard: 比赛成绩表 contest_balloon: 气球状态 +contest_clarification: 赛时答疑 contest_create: 创建比赛 contest_detail_problem_submit: 递交比赛题目 contest_detail_problem: 题目详情 @@ -476,6 +477,7 @@ Manage: 管理 Manager: 管理 Mark Green: 标绿 Mark Yellow: 标黄 +Materials: 材料 Max files for user: 单用户最大文件数量 Max files per problem: 单题最大文件数量 Max files size per problem: 单题最大总文件大小 @@ -501,7 +503,6 @@ name: 名称 Name: 名称 Network error: 网络错误 Never expire: 从不过期 -New dataset: 新测试数据 New Email: 新电子邮件 New file: 新文件 New Password: 新密码 @@ -532,10 +533,11 @@ Oh, the user doesn't have any contributions!: 啊哦,这个用户还没贡献 Oh, the user hasn't created any discussions yet!: 这个用户还没有发布过讨论 Oh, the user hasn't submitted yet!: 这个用户还没有交过题 _(:зゝ∠)_ Oh, there are no tasks that match the filter!: 喔,目前没有符合过滤条件的任务。 -Oh, there is no clarification!: 喔,目前没有提问! +Oh, there is no clarification!: 喔,目前没有答疑! Oh, there is no task in the queue!: 喔,队列中目前没有任务。 Ok: 确定 Only A-Z, a-z, 0-9 and _ are accepted: 只接受 A-Z, a-z, 0-9 和 _ +Only Partic.: 仅参赛者 Oops, there are no results.: 呀,没有结果。 Oops!: Oops! Open in Problem Set: 在题库中打开 diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index f082036cf..b783b4694 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -14,7 +14,7 @@ import { ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError, } from '../error'; -import { ScoreboardConfig, Tdoc } from '../interface'; +import { FileInfo, ScoreboardConfig, Tdoc } from '../interface'; import { PERM, PRIV, STATUS } from '../model/builtin'; import * as contest from '../model/contest'; import * as discussion from '../model/discussion'; @@ -25,7 +25,6 @@ import problem from '../model/problem'; import record from '../model/record'; import ScheduleModel from '../model/schedule'; import storage from '../model/storage'; -import * as system from '../model/system'; import user from '../model/user'; import { Handler, param, post, Type, Types, @@ -156,13 +155,13 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), udict, - files: sortFiles(this.tdoc.files || []), - urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }), + files: (this.tsdoc?.attend && !contest.isNotStarted(this.tdoc)) ? sortFiles(this.tdoc.privateFiles || []) : [], + urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'private' }), }; if (this.request.json) return; this.response.body.tdoc.content = this.response.body.tdoc.content - .replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`) - .replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`); + .replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/public/`) + .replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/public/`); } @param('tid', Types.ObjectId) @@ -312,9 +311,9 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { await contest.addClarification(domainId, tid, this.user._id, content, this.request.ip, subject); if (!this.user.own(this.tdoc)) { await message.send(1, this.tdoc.maintainer.concat(this.tdoc.owner), JSON.stringify({ - message: 'Contest {0} has a new clarification about {1}, please go to contest management to reply.', + message: 'Contest {0} has a new clarification about {1}, please go to contest clarifications page to reply.', params: [this.tdoc.title, subject > 0 ? `#${this.tdoc.pids.indexOf(subject) + 1}` : 'the contest'], - url: this.url('contest_manage', { tid }), + url: this.url('contest_clarification', { tid }), }), message.FLAG_I18N | message.FLAG_UNREAD); } this.back(); @@ -354,6 +353,8 @@ export class ContestEditHandler extends Handler { pids: tid ? this.tdoc.pids.join(',') : '', beginAt, page_name: tid ? 'contest_edit' : 'contest_create', + files: tid ? this.tdoc.files : [], + urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'public' }), }; } @@ -439,7 +440,11 @@ export class ContestEditHandler extends Handler { ScheduleModel.deleteMany({ type: 'schedule', subType: 'contest', domainId, tid, }), - storage.del(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id), + storage.del( + (this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/public/${i.name}`) || []) + .concat(this.tdoc.privateFiles?.map((i) => `contest/${domainId}/${tid}/private/${i.name}`) || []), + this.user._id, + ), ])); this.response.redirect = this.url('contest_main'); } @@ -502,22 +507,95 @@ export class ContestCodeHandler extends Handler { export class ContestManagementHandler extends ContestManagementBaseHandler { @param('tid', Types.ObjectId) async get(domainId: string, tid: ObjectId) { - const tcdocs = await contest.getMultiClarification(domainId, tid); this.response.body = { tdoc: this.tdoc, tsdoc: this.tsdoc, owner_udoc: await user.getById(domainId, this.tdoc.owner), pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']), files: sortFiles(this.tdoc.files || []), + privateFiles: sortFiles(this.tdoc.privateFiles || []), + urlForFile: (filename: string, type: string) => this.url('contest_file_download', { tid, filename, type }), + }; + this.response.pjax = [ + ['partials/files.html', { filetype: 'public' }], + ['partials/files.html', { + files: this.response.body.privateFiles, + filetype: 'private', + }], + ]; + this.response.template = 'contest_manage.html'; + } + + @param('tid', Types.ObjectId) + @post('filename', Types.Filename, true) + @post('type', Types.Range(['private', 'public']), true) + async postUploadFile(domainId: string, tid: ObjectId, filename: string, type: 'private' | 'public' = 'private') { + const allFiles = [...(this.tdoc.files || []), ...(this.tdoc.privateFiles || [])]; + if (allFiles.length >= this.ctx.setting.get('limit.contest_files')) { + throw new FileLimitExceededError('count'); + } + const file = this.request.files?.file; + if (!file) throw new ValidationError('file'); + if (Math.sum(allFiles.map((i) => i.size)) + file.size >= this.ctx.setting.get('limit.contest_files_size')) { + throw new FileLimitExceededError('size'); + } + filename ||= file.originalFilename || randomstring(16); + const target = `contest/${domainId}/${tid}/${type}/${filename}`; + await storage.put(target, file.filepath, this.user._id); + const meta = await storage.getMeta(target); + const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; + if (!meta) throw new FileUploadError(); + const updateList = (files: FileInfo[], newFile: FileInfo) => (files || []).filter((i) => i._id !== newFile._id).concat(newFile); + await contest.edit(domainId, tid, { + files: type === 'private' ? this.tdoc.files : updateList(this.tdoc.files, payload), + privateFiles: type === 'private' ? updateList(this.tdoc.privateFiles, payload) : this.tdoc.privateFiles, + }); + this.back(); + } + + @param('tid', Types.ObjectId) + @post('files', Types.ArrayOf(Types.Filename)) + @post('type', Types.Range(['public', 'private']), true) + async postDeleteFiles(domainId: string, tid: ObjectId, files: string[], type = 'private') { + await Promise.all([ + storage.del(files.map((t) => `contest/${domainId}/${tid}/${type}/${t}`), this.user._id), + contest.edit(domainId, tid, type === 'private' + ? { privateFiles: this.tdoc.privateFiles?.filter((i) => !files.includes(i.name)) } + : { files: this.tdoc.files?.filter((i) => !files.includes(i.name)) }, + ), + ]); + this.back(); + } + + @param('pid', Types.PositiveInt) + @param('score', Types.PositiveInt) + async postSetScore(domainId: string, pid: number, score: number) { + if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid'); + this.tdoc.score ||= {}; + this.tdoc.score[pid] = score; + await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score }); + await contest.recalcStatus(domainId, this.tdoc.docId); + this.back(); + } +} + +class ContestClarificationHandler extends ContestManagementBaseHandler { + @param('tid', Types.ObjectId) + async get(domainId: string, tid: ObjectId) { + const tcdocs = await contest.getMultiClarification(domainId, tid); + this.response.body = { + tdoc: this.tdoc, + tsdoc: this.tsdoc, + owner_udoc: await user.getById(domainId, this.tdoc.owner), + pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']), + tcdocs, udict: await user.getListForRender( domainId, tcdocs.map((i) => i.owner), this.user.hasPerm(PERM.PERM_VIEW_USER_PRIVATE_INFO), ), - tcdocs, - urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }), }; - this.response.pjax = 'partials/files.html'; - this.response.template = 'contest_manage.html'; + this.response.pjax = 'partials/contest_clarification.html'; + this.response.template = 'contest_clarification.html'; } @param('tid', Types.ObjectId) @@ -550,57 +628,21 @@ export class ContestManagementHandler extends ContestManagementBaseHandler { } this.back(); } - - @param('tid', Types.ObjectId) - @post('filename', Types.Filename, true) - async postUploadFile(domainId: string, tid: ObjectId, filename: string) { - if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) { - throw new FileLimitExceededError('count'); - } - const file = this.request.files?.file; - if (!file) throw new ValidationError('file'); - const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + file.size; - if (size >= system.get('limit.contest_files_size')) { - throw new FileLimitExceededError('size'); - } - filename ||= file.originalFilename || randomstring(16); - await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id); - const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`); - const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; - if (!meta) throw new FileUploadError(); - await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] }); - this.back(); - } - - @param('tid', Types.ObjectId) - @post('files', Types.ArrayOf(Types.Filename)) - async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) { - await Promise.all([ - storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id), - contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }), - ]); - this.back(); - } - - @param('pid', Types.PositiveInt) - @param('score', Types.PositiveInt) - async postSetScore(domainId: string, pid: number, score: number) { - if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid'); - this.tdoc.score ||= {}; - this.tdoc.score[pid] = score; - await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score }); - await contest.recalcStatus(domainId, this.tdoc.docId); - this.back(); - } } export class ContestFileDownloadHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectId) @param('filename', Types.Filename) @param('noDisposition', Types.Boolean) - async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false) { + @param('type', Types.Range(['public', 'private']), true) + async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false, type = 'private') { + if (type === 'private' && !this.user.own(this.tdoc)) { + if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); + if (!contest.isOngoing(this.tdoc) && !contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid); + if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); + } this.response.addHeader('Cache-Control', 'public'); - const target = `contest/${domainId}/${tid}/${filename}`; + const target = `contest/${domainId}/${tid}/${type}/${filename}`; const file = await storage.getMeta(target); await oplog.log(this, 'download.file.contest', { target, @@ -805,8 +847,9 @@ export async function apply(ctx: Context) { ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler); + ctx.Route('contest_clarification', '/contest/:tid/clarification', ContestClarificationHandler); ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); - ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); + ctx.Route('contest_file_download', '/contest/:tid/file/:type/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_balloon', '/contest/:tid/balloon', ContestBalloonHandler, PERM.PERM_VIEW_CONTEST); ctx.worker.addHandler('contest', async (doc) => { diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index 9e22b7c94..12fffcb30 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -247,7 +247,7 @@ class HomeworkEditHandler extends Handler { await Promise.all([ record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' }), contest.del(domainId, tid), - storage.del(tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id), + storage.del(tdoc.files?.map((i) => `contest/${domainId}/${tid}/private/${i.name}`) || [], this.user._id), ]); this.response.redirect = this.url('homework_main'); } @@ -289,8 +289,8 @@ export class HomeworkFilesHandler extends Handler { if (size >= system.get('limit.contest_files_size')) { throw new FileLimitExceededError('size'); } - await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id); - const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`); + await storage.put(`contest/${domainId}/${tid}/private/${filename}`, file.filepath, this.user._id); + const meta = await storage.getMeta(`contest/${domainId}/${tid}/private/${filename}`); const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; if (!meta) throw new FileUploadError(); await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] }); @@ -301,7 +301,7 @@ export class HomeworkFilesHandler extends Handler { @post('files', Types.ArrayOf(Types.Filename)) async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) { await Promise.all([ - storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id), + storage.del(files.map((t) => `contest/${domainId}/${tid}/private/${t}`), this.user._id), contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }), ]); this.back(); diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index eb4418c01..540a3c493 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -646,28 +646,15 @@ export class ProblemFilesHandler extends ProblemDetailHandler { notUsage = true; @param('d', Types.CommaSeperatedArray, true) - @param('pjax', Types.Boolean) @param('sidebar', Types.Boolean) - async get(domainId: string, d = ['testdata', 'additional_file'], pjax = false, sidebar = false) { + async get({ }, d = ['testdata', 'additional_file'], sidebar = false) { if (this.tdoc) throw new ContestNotEndedError(); - this.response.body.testdata = d.includes('testdata') ? sortFiles(this.pdoc.data || []) : []; + this.response.body.testdata = sortFiles(this.pdoc.data || []); + this.response.body.additional_file = sortFiles(this.pdoc.additional_file || []); this.response.body.reference = this.pdoc.reference; - this.response.body.additional_file = d.includes('additional_file') ? sortFiles(this.pdoc.additional_file || []) : []; - if (pjax) { - const { testdata, additional_file } = this.response.body; - const owner = await user.getById(domainId, this.pdoc.owner); - const args = { - testdata, additional_file, pdoc: this.pdoc, owner_udoc: owner, sidebar, can_edit: true, - }; - const tasks = []; - if (d.includes('testdata')) tasks.push(this.renderHTML('partials/problem_files.html', { ...args, filetype: 'testdata' })); - if (d.includes('additional_file')) tasks.push(this.renderHTML('partials/problem_files.html', { ...args, filetype: 'additional_file' })); - if (!sidebar) tasks.push(this.renderHTML('partials/problem-sidebar-information.html', args)); - this.response.body = { - fragments: (await Promise.all(tasks)).map((i) => ({ html: i })), - }; - this.response.template = ''; - } else this.response.template = 'problem_files.html'; + this.response.pjax = d.map((i) => ['partials/problem_files.html', { filetype: i, sidebar, can_edit: true }]); + if (!sidebar) this.response.pjax.push(['partials/problem-sidebar-information.html', {}]); + this.response.template = 'problem_files.html'; } async post() { diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 597109dc0..77cd6d7eb 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -264,6 +264,7 @@ export interface Tdoc extends Document { _code?: string; assign?: string[]; files?: FileInfo[]; + privateFiles?: FileInfo[]; allowViewCode?: boolean; allowPrint?: boolean; diff --git a/packages/hydrooj/src/model/storage.ts b/packages/hydrooj/src/model/storage.ts index 5b817b3b1..b0094d738 100644 --- a/packages/hydrooj/src/model/storage.ts +++ b/packages/hydrooj/src/model/storage.ts @@ -43,10 +43,10 @@ export class StorageModel { return await storage.get(value?.link || value?._id || path, savePath); } - static async rename(path: string, newPath: string, operator = 1) { + static async rename(path: string, newPath: string, operator: null | number = 1) { return await StorageModel.coll.updateOne( { path, autoDelete: null }, - { $set: { path: newPath }, $push: { operator } }, + { $set: { path: newPath }, ...(operator !== null ? { $push: { operator } } : {}) }, ); } diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index 2e73875dd..34ba84f31 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -617,4 +617,12 @@ export const coreScripts: MigrationScript[] = [ await discussion.coll.deleteMany({ content: { $not: { $type: 'string' } } }); return true; }, + async function _95_96() { + const files = await StorageModel.list('contest/', true); + for (const file of files) { + const [, domainId, tid, type, name] = file.path.split('/'); + if (!name) await StorageModel.rename(file.path, `contest/${domainId}/${tid}/public/${type}`); + } + return true; + }, ]; diff --git a/packages/ui-default/components/upload.tsx b/packages/ui-default/components/upload.tsx index abbb4d12f..ce943ac80 100644 --- a/packages/ui-default/components/upload.tsx +++ b/packages/ui-default/components/upload.tsx @@ -1,5 +1,6 @@ import { Classes } from '@blueprintjs/core'; import $ from 'jquery'; +import React from 'react'; import { Dialog } from 'vj/components/dialog/index'; import Notification from 'vj/components/notification'; import { @@ -82,9 +83,9 @@ export default async function uploadFiles(endpoint = '', files: File[] | FileLis Notification.success(i18n('File uploaded successfully.')); if (options.pjax) { let params = ''; - if (options.type) params += `? d = ${options.type} `; - if (options.sidebar) params += `${params ? '&' : '?'} sidebar = true`; - await pjax.request({ push: false, url: `${endpoint}${params || ''} ` }); + if (options.type) params += `?d=${encodeURIComponent(options.type)}`; + if (options.sidebar) params += `${params ? '&' : '?'}sidebar=true`; + await pjax.request({ push: false, url: `${endpoint}${params || ''}` }); } } catch (e) { console.error(e); diff --git a/packages/ui-default/locales/ko.yaml b/packages/ui-default/locales/ko.yaml index 6c3c644e2..d283cdc0f 100644 --- a/packages/ui-default/locales/ko.yaml +++ b/packages/ui-default/locales/ko.yaml @@ -432,7 +432,6 @@ name: 이름 Name: 이름 Network error: Network error Never expire: 영원히 만료되지 않음 -New dataset: 새 데이터셋 New Email: 새 이메일 New file: 새 파일 New Password: 새 비밀번호 diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 903c8f1cc..007b564cc 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -86,6 +86,7 @@ Add suffix: 添加后缀 Add testcase: 添加测试点 Add User: 添加用户 Add: 添加 +Additional Files: 附加文件 additional_file: 附加文件 All {0} Contests: 所有 {0} 比赛 All changes will be lost. Are you sure to clear all answers?: 所有更改将会丢失。您确定要清除所有答案吗? @@ -391,8 +392,8 @@ Failed to parse testcase.: 无法解析测试点信息。 Feedback: 反馈 Field {0} or {1} validation failed.: 字段 {0} 或 {1} 验证失败。 Field {0} validation failed.: 字段 {0} 验证失败。 -File have been deleted.: 文件已被删除。 -File have been renamed.: 文件已被重命名。 +File has been deleted.: 文件已被删除。 +File has been renamed.: 文件已被重命名。 File saved.: 文件已成功保存。 file too large: 文件过大 File uploaded successfully.: 上传文件成功 @@ -585,9 +586,8 @@ Nav Logo Light: 左上角图标(亮) Nav Logo Light@2x: 左上角图标(亮)(大) Network error: 网络错误 Never expire: 从不过期 -New dataset: 新测试数据 New Email: 新电子邮件 -New file: 新文件 +New Filename: 新文件名 New filename(s): 新文件名 New Password: 新密码 New Training Plan: 新训练计划 diff --git a/packages/ui-default/pages/contest.page.styl b/packages/ui-default/pages/contest.page.styl index b61b85aa9..cd9aa1ef5 100644 --- a/packages/ui-default/pages/contest.page.styl +++ b/packages/ui-default/pages/contest.page.styl @@ -217,5 +217,7 @@ $highlight-button-color = #F6DF45 width: rem(130px) .page--contest_manage + .col--id + width: rem(80px) .col--score width: rem(80px) \ No newline at end of file diff --git a/packages/ui-default/pages/contest_clarification.page.tsx b/packages/ui-default/pages/contest_clarification.page.tsx new file mode 100644 index 000000000..7c7fe67e0 --- /dev/null +++ b/packages/ui-default/pages/contest_clarification.page.tsx @@ -0,0 +1,25 @@ +import $ from 'jquery'; +import { NamedPage } from 'vj/misc/Page'; + +function handleReplyOrBroadcast(ev) { + const title = $(ev.currentTarget).data('title'); + const did = $(ev.currentTarget).data('did'); + + $('#reply_or_broadcast .section_title').text(title); + $('#reply_or_broadcast [name="did"]').val(did ?? ''); + const $item = $(`#clarification_${did} .media`); + if ($item.length) { + $('#reply_or_broadcast .form__item_subject').hide(); + $('#reply_or_broadcast .clarification-container').empty().append($item.clone()); + } else { + $('#reply_or_broadcast .form__item_subject').show(); + $('#reply_or_broadcast .clarification-container').empty(); + } +} + +const page = new NamedPage('contest_clarification', () => { + $(document).on('click', '[name="broadcast"]', handleReplyOrBroadcast); + $(document).on('click', '[name="reply"]', handleReplyOrBroadcast); +}); + +export default page; diff --git a/packages/ui-default/pages/contest_manage.page.ts b/packages/ui-default/pages/contest_manage.page.ts index 173272aa4..5bac9c222 100644 --- a/packages/ui-default/pages/contest_manage.page.ts +++ b/packages/ui-default/pages/contest_manage.page.ts @@ -4,25 +4,7 @@ import Notification from 'vj/components/notification'; import { NamedPage } from 'vj/misc/Page'; import { i18n, request, tpl } from 'vj/utils'; -function handleReplyOrBroadcast(ev) { - const title = $(ev.currentTarget).data('title'); - const did = $(ev.currentTarget).data('did'); - - $('#reply_or_broadcast .section_title').text(title); - $('#reply_or_broadcast [name="did"]').val(did ?? ''); - const $item = $(`#clarification_${did} .media`); - if ($item.length) { - $('#reply_or_broadcast .form__item_subject').hide(); - $('#reply_or_broadcast .clarification-container').empty().append($item.clone()); - } else { - $('#reply_or_broadcast .form__item_subject').show(); - $('#reply_or_broadcast .clarification-container').empty(); - } -} - const page = new NamedPage('contest_manage', () => { - $(document).on('click', '[name="broadcast"]', handleReplyOrBroadcast); - $(document).on('click', '[name="reply"]', handleReplyOrBroadcast); $(document).on('click', '[name="set_score"]', async (ev) => { const pid = $(ev.currentTarget).data('pid'); const op = await new ActionDialog({ diff --git a/packages/ui-default/pages/files.page.js b/packages/ui-default/pages/files.page.js deleted file mode 100644 index 48c6f3ddf..000000000 --- a/packages/ui-default/pages/files.page.js +++ /dev/null @@ -1,102 +0,0 @@ -import $ from 'jquery'; -import _ from 'lodash'; -import { ConfirmDialog } from 'vj/components/dialog/index'; -import Notification from 'vj/components/notification'; -import uploadFiles from 'vj/components/upload'; -import { NamedPage } from 'vj/misc/Page'; -import { - i18n, pjax, request, tpl, -} from 'vj/utils'; - -function ensureAndGetSelectedFiles() { - const files = _.map( - $('.files tbody [data-checkbox-group="files"]:checked'), - (ch) => $(ch).closest('tr').attr('data-filename'), - ); - if (files.length === 0) { - Notification.error(i18n('Please select at least one file to perform this operation.')); - return null; - } - return files; -} - -async function handleClickUpload(files) { - if (!files) { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - input.click(); - await new Promise((resolve) => { input.onchange = resolve; }); - files = input.files; - } - if (!files.length) { - Notification.warn(i18n('No file selected.')); - return; - } - await uploadFiles('', files, { pjax: true }); -} - -async function handleClickRemoveSelected() { - const selectedFiles = ensureAndGetSelectedFiles(); - if (selectedFiles === null) return; - const action = await new ConfirmDialog({ - $body: tpl` -
-

${i18n('Confirm to delete the selected files?')}

-
`, - }).open(); - if (action !== 'yes') return; - try { - await request.post('', { - operation: 'delete_files', - files: selectedFiles, - }); - Notification.success(i18n('Selected files have been deleted.')); - await pjax.request({ push: false }); - } catch (error) { - Notification.error(error.message); - } -} - -/** - * @param {JQuery.DragOverEvent} ev - */ -function handleDragOver(ev) { - ev.preventDefault(); - // TODO display a drag-drop allowed hint -} - -/** - * @param {JQuery.DropEvent} ev - */ -function handleDrop(ev) { - ev.preventDefault(); - if (!$('[name="upload_file"]').length) { - Notification.error(i18n("You don't have permission to upload file.")); - return; - } - ev = ev.originalEvent; - const files = []; - if (ev.dataTransfer.items) { - for (let i = 0; i < ev.dataTransfer.items.length; i++) { - if (ev.dataTransfer.items[i].kind === 'file') { - const file = ev.dataTransfer.items[i].getAsFile(); - files.push(file); - } - } - } else { - for (let i = 0; i < ev.dataTransfer.files.length; i++) { - files.push(ev.dataTransfer.files[i]); - } - } - handleClickUpload(files); -} - -const page = new NamedPage(['home_files', 'contest_manage', 'training_files', 'homework_files'], () => { - $(document).on('click', '[name="upload_file"]', () => handleClickUpload()); - $(document).on('click', '[name="remove_selected"]', () => handleClickRemoveSelected()); - $(document).on('dragover', '.files', (ev) => handleDragOver(ev)); - $(document).on('drop', '.files', (ev) => handleDrop(ev)); -}); - -export default page; diff --git a/packages/ui-default/pages/files.page.styl b/packages/ui-default/pages/files.page.styl index 3a3b6873e..2841cbb71 100644 --- a/packages/ui-default/pages/files.page.styl +++ b/packages/ui-default/pages/files.page.styl @@ -1,9 +1,24 @@ -.page--home_files, .page--training_files, .page--contest_detail, .page--contest_manage, .page--homework_files +.page--home_files, .page--training_files, .page--contest_detail, .page--contest_manage, .page--homework_files, .page--contest_edit, .page--problem_edit, .page--problem_config, .page--problem_files .col--checkbox width: 60px .col--size - width: 100px + width: 80px .col--operation width: 40px + +.page--problem_edit, .page--problem_config, .page--problem_files + .col--operation + width: 60px + + .problem-sidebar-additional_file, .problem-sidebar-testdata + height: 300px + overflow-y: auto + + .rename-confirm-table + overflow-x: auto + + .col--origin, .col--new + width: 50% + text-align: center \ No newline at end of file diff --git a/packages/ui-default/pages/files.page.tsx b/packages/ui-default/pages/files.page.tsx new file mode 100644 index 000000000..4efd6d72a --- /dev/null +++ b/packages/ui-default/pages/files.page.tsx @@ -0,0 +1,348 @@ +import $ from 'jquery'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { ActionDialog, confirm, prompt } from 'vj/components/dialog/index'; +import Notification from 'vj/components/notification'; +import uploadFiles from 'vj/components/upload'; +import { NamedPage } from 'vj/misc/Page'; +import { + i18n, pjax, request, tpl, +} from 'vj/utils'; + +let endpoint = ''; + +const getUrl = (type: string, sidebar: boolean) => { + let url = endpoint; + const search = new URLSearchParams(); + if (type) search.append('d', type); + if (sidebar) search.append('sidebar', 'true'); + if (search.size) url += `?${search.toString()}`; + return url; +}; + +const extractArgsFromEvent = (ev) => { + return { + file: $(ev.currentTarget).parent().parent().attr('data-filename'), + type: $(ev.target).closest('[data-type]').attr('data-type') || '', + sidebar: !!$(ev.target).closest('[data-sidebar]').length, + }; +}; + +function ensureAndGetSelectedFiles(type = '') { + const allChecked = $(`.files tbody [data-checkbox-group="${type || 'files'}"]:checked`); + const files = allChecked.get().map((i) => $(i).closest('tr').attr('data-filename')); + if (files.length === 0) { + Notification.error(i18n('Please select at least one file to perform this operation.')); + return null; + } + return files; +} + +async function handleClickUpload( + ev: JQuery.ClickEvent | JQuery.DropEvent, + files?: File[] | FileList, +) { + const { type, sidebar } = extractArgsFromEvent(ev); + if (!files) { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.click(); + await new Promise((resolve) => { input.onchange = resolve; }); + files = input.files; + } + if (!files.length) { + Notification.warn(i18n('No file selected.')); + return; + } + await uploadFiles(endpoint, files, { pjax: true, type, sidebar }); +} + +async function handleClickRename(ev) { + const { file, type, sidebar } = extractArgsFromEvent(ev); + const res = await prompt(i18n('Enter a new name for the file: '), { + name: { + type: 'text', + required: true, + label: i18n('New Filename'), + }, + }); + if (!res?.name) return; + try { + await request.post(endpoint, { + operation: 'rename_files', + files: [file], + newNames: [res.name], + type, + }); + Notification.success(i18n('File has been renamed.')); + await pjax.request({ url: getUrl(type, sidebar), push: false }); + } catch (error) { + Notification.error(error.message); + } +} + +async function handleClickRenameSelected(ev) { + const { type, sidebar } = extractArgsFromEvent(ev); + const selectedFiles = ensureAndGetSelectedFiles(type); + if (!selectedFiles?.length) return; + let onActionButton = (_: string) => false; + + function Rename(props) { + const [original, setOriginal] = React.useState(''); + const [replace, setReplace] = React.useState(''); + const [prefix, setPrefix] = React.useState(''); + const [suffix, setSuffix] = React.useState(''); + const [regexValid, setRegexValid] = React.useState(true); + const [wantNext, setWantNext] = React.useState(false); + const [preview, setPreview] = React.useState(false); + const [highlight, setHighlight] = React.useState(null); + const [newNames, setNewNames] = React.useState(props.names); + const [prism, setPrism] = React.useState(null); + + React.useEffect(() => { + const load = import('../components/highlighter/prismjs'); + load.then(({ default: p }) => { + setPrism(p.Prism); + }).catch(() => { }); + }, []); + + React.useEffect(() => { + let s: string | RegExp = original; + setRegexValid(true); + setHighlight(null); + if (original.length > 2 && original.startsWith('/')) { + const availableFlags = ['g', 'i']; + const flags = []; + let copy = original.substring(1); + while (availableFlags.includes(copy[copy.length - 1])) { + flags.push(copy[copy.length - 1]); + copy = copy.substring(0, copy.length - 1); + } + if (copy.endsWith('/')) { + copy = copy.substring(0, copy.length - 1); + flags.reverse(); + if (prism) setHighlight(`/${prism.highlight(copy, prism.languages.regex, 'RegExp')}/${flags.join('')}`); + try { + s = new RegExp(copy, flags.join('')); + setRegexValid(true); + } catch (e) { + setRegexValid(false); + } + } + } + setNewNames(selectedFiles.map((file) => { + if (s) file = file.replace(s, replace); + return prefix + file + suffix; + })); + }, [original, replace, prefix, suffix, prism]); + + onActionButton = (action) => { + if (action === 'ok') { + if (!preview) { + if (!regexValid) return false; + if (!original && !prefix && !suffix) { + setWantNext(true); + return false; + } + setPreview(true); + return false; + } + request.post(endpoint, { + operation: 'rename_files', + files: selectedFiles, + newNames, + type, + }).then(() => { + Notification.success(i18n('Selected files have been renamed.')); + pjax.request({ url: getUrl(type, sidebar), push: false }); + }).catch((error) => { + Notification.error(error.message); + }); + return true; + } + if (preview) { + setPreview(false); + return false; + } + return true; + }; + + const style = { fontFamily: 'var(--code-font-family)' }; + + return
+ {!preview ? <> +
+
+

{i18n('Batch replacement')}

+ + +
+
+

{i18n('Add prefix/suffix')}

+ + +
+
+
+
+

{!regexValid ? i18n('Invalid RegExp') : wantNext ? i18n('No changes to make.') : i18n('RegExp supported, quote with "/"')}

+
+
+ :
+

{i18n('Are you sure to rename the following file?')}

+
    + {original &&
  • Replace {original} with {replace}
  • } + {prefix &&
  • Add {prefix} as prefix
  • } + {suffix &&
  • Add {suffix} as suffix
  • } +
+ + + + + + + + + + + + + {selectedFiles.map((file, index) => + + + )} + +
{i18n('Original filename(s)')}{i18n('New filename(s)')}
{file}{newNames[index]}
+
} +
; + } + + const promise = new ActionDialog({ + $body: tpl`
`, + width: '600px', + onDispatch(action) { + return onActionButton(action); + }, + }).open(); + const root = ReactDOM.createRoot(document.getElementById('rename_dialog')); + root.render(); + await promise; + root.unmount(); +} + +async function handleClickRemove(ev) { + const { file, type, sidebar } = extractArgsFromEvent(ev); + if (!(await confirm(i18n('Confirm to delete the file?')))) return; + try { + await request.post(endpoint, { + operation: 'delete_files', + files: [file], + type, + }); + Notification.success(i18n('File has been deleted.')); + await pjax.request({ url: getUrl(type, sidebar), push: false }); + } catch (error) { + Notification.error(error.message); + } +} + +async function handleClickRemoveSelected(ev: JQuery.ClickEvent) { + const { type, sidebar } = extractArgsFromEvent(ev); + const selectedFiles = ensureAndGetSelectedFiles(type); + if (selectedFiles === null) return; + if (!(await confirm(i18n('Confirm to delete the selected files?')))) return; + try { + await request.post(endpoint, { + operation: 'delete_files', + files: selectedFiles, + type, + }); + Notification.success(i18n('Selected files have been deleted.')); + await pjax.request({ url: getUrl(type, sidebar), push: false }); + } catch (error) { + Notification.error(error.message); + } +} + +function handleDragOver(ev: JQuery.DragOverEvent) { + ev.preventDefault(); + // TODO display a drag-drop allowed hint +} + +function handleDrop(e: JQuery.DropEvent) { + e.preventDefault(); + if (!$('[name="upload_file"]').length) { + Notification.error(i18n("You don't have permission to upload file.")); + return; + } + const ev = e.originalEvent; + const files = []; + if (ev.dataTransfer.items) { + for (let i = 0; i < ev.dataTransfer.items.length; i++) { + if (ev.dataTransfer.items[i].kind === 'file') { + const file = ev.dataTransfer.items[i].getAsFile(); + files.push(file); + } + } + } else { + for (let i = 0; i < ev.dataTransfer.files.length; i++) { + files.push(ev.dataTransfer.files[i]); + } + } + handleClickUpload(e, files); +} + +const page = new NamedPage([ + 'problem_files', 'problem_edit', 'contest_edit', 'contest_manage', + 'home_files', 'training_files', 'homework_files', +], (pageName) => { + if (pageName === 'problem_edit') endpoint = './files'; + $(document).on('click', '[name="file_rename"]', (ev) => handleClickRename(ev)); + $(document).on('click', '[name="file_remove"]', (ev) => handleClickRemove(ev)); + $(document).on('click', '[name="upload_file"]', (ev) => handleClickUpload(ev)); + $(document).on('click', '[name="rename_selected"]', (ev) => handleClickRenameSelected(ev)); + $(document).on('click', '[name="remove_selected"]', (ev) => handleClickRemoveSelected(ev)); + $(document).on('dragover', '.files', (ev) => handleDragOver(ev)); + $(document).on('drop', '.files', (ev) => handleDrop(ev)); +}); + +export default page; diff --git a/packages/ui-default/pages/problem_config.page.styl b/packages/ui-default/pages/problem_config.page.styl index 3ea6c9908..15d4d7b50 100644 --- a/packages/ui-default/pages/problem_config.page.styl +++ b/packages/ui-default/pages/problem_config.page.styl @@ -1,14 +1,4 @@ .page--problem_config - .problem-sidebar-testdata - max-height: 270px - overflow-y: auto - - .col--size - width: 80px - .col--operation - width: 60px - text-align: center - .textbox,.select margin: 0 diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx index 331cbe803..93f750478 100644 --- a/packages/ui-default/pages/problem_config.page.tsx +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -52,7 +52,7 @@ const page = new NamedPage('problem_config', () => { newNames: [newName], type: 'testdata', }); - Notification.success(i18n('File have been renamed.')); + Notification.success(i18n('File has been renamed.')); await pjax.request({ url: './files?d=testdata&sidebar=true', push: false }); } catch (error) { Notification.error(error.message); @@ -68,7 +68,7 @@ const page = new NamedPage('problem_config', () => { files: file, type: 'testdata', }); - Notification.success(i18n('File have been deleted.')); + Notification.success(i18n('File has been deleted.')); reduxStore.dispatch({ type: 'CONFIG_DELETE_TESTDATA', value: file, diff --git a/packages/ui-default/pages/problem_edit.page.js b/packages/ui-default/pages/problem_edit.page.js index dec63b918..a273f324c 100644 --- a/packages/ui-default/pages/problem_edit.page.js +++ b/packages/ui-default/pages/problem_edit.page.js @@ -1,15 +1,11 @@ import $ from 'jquery'; import _ from 'lodash'; -import { ConfirmDialog } from 'vj/components/dialog'; +import { confirm } 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 uploadFiles from 'vj/components/upload'; -import download from 'vj/components/zipDownloader'; import { NamedPage } from 'vj/misc/Page'; -import { - i18n, pjax, request, slideDown, slideUp, tpl, -} from 'vj/utils'; +import { i18n, request } from 'vj/utils'; const categories = {}; const dirtyCategories = []; @@ -139,21 +135,6 @@ function buildCategoryFilter() { }); } -async function handleSection(ev, sidebar, type) { - const $section = $(ev.currentTarget).closest(`.section--problem-sidebar-${sidebar}`); - if ($section.is(`.${type}d, .animating`)) return; - $section.addClass('animating'); - const $detail = $section.find(`.section--problem-sidebar-${sidebar}__detail`); - if (type === 'expand') { - await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 }); - } else { - await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 }); - } - $section.addClass(type === 'expand' ? 'expanded' : 'collapsed'); - $section.removeClass(type === 'expand' ? 'collapsed' : 'expanded'); - $section.removeClass('animating'); -} - export default new NamedPage(['problem_create', 'problem_edit'], () => { let confirmed = false; $(document).on('click', '[name="operation"]', (ev) => { @@ -165,14 +146,8 @@ export default new NamedPage(['problem_create', 'problem_edit'], () => { 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` -
-

${i18n(message)}

-
`, - }).open().then((action) => { - if (action !== 'yes') return; + return confirm(i18n('Confirm deleting this problem? Its files, submissions, discussions and solutions will be deleted as well.')).then((yes) => { + if (!yes) return; confirmed = true; ev.target.click(); }); @@ -181,65 +156,6 @@ export default new NamedPage(['problem_create', 'problem_edit'], () => { 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; }); - await uploadFiles('./files', input.files, { - type: 'additional_file', - sidebar: true, - pjax: true, - }); - } - - async function handleClickRename(ev) { - const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; - // eslint-disable-next-line no-alert - const newName = prompt(i18n('Enter a new name for the file: ')); - if (!newName) return; - try { - await request.post('./files', { - operation: 'rename_files', - files: file, - newNames: [newName], - type: 'additional_file', - }); - Notification.success(i18n('File have been renamed.')); - await pjax.request({ url: './files?d=additional_file&sidebar=true', push: false }); - } catch (error) { - Notification.error(error.message); - } - } - - 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.')); - await pjax.request({ url: './files?d=additional_file&sidebar=true', push: false }); - } catch (error) { - Notification.error(error.message); - } - } - - async function handleClickDownloadAll() { - const files = $('.additional_file-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); - } - const $main = $('textarea[data-editor]'); const $field = $('textarea[data-markdown-upload]'); let content = $field.val(); @@ -294,12 +210,4 @@ export default new NamedPage(['problem_create', 'problem_edit'], () => { ev.preventDefault(); } }); - $(document).on('click', '[name="additional_file__upload"]', () => handleClickUpload()); - $(document).on('click', '[name="additional_file__rename"]', (ev) => handleClickRename(ev)); - $(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')); }); diff --git a/packages/ui-default/pages/problem_edit.page.styl b/packages/ui-default/pages/problem_edit.page.styl deleted file mode 100644 index ba0a7d6ab..000000000 --- a/packages/ui-default/pages/problem_edit.page.styl +++ /dev/null @@ -1,10 +0,0 @@ -.page--problem_edit,.page--problem_create - .problem-sidebar-additional_file - height: 270px - overflow-y: auto - - .col--size - width: 80px - .col--operation - width: 60px - text-align: center \ No newline at end of file diff --git a/packages/ui-default/pages/problem_files.page.styl b/packages/ui-default/pages/problem_files.page.styl deleted file mode 100644 index a2020f82f..000000000 --- a/packages/ui-default/pages/problem_files.page.styl +++ /dev/null @@ -1,17 +0,0 @@ -.page--problem_files - .col--checkbox - width: 60px - - .col--size - width: 120px - - .col--operation - width: 60px - text-align: center - - .rename-confirm-table - x-overflow: auto - - .col--origin, .col--new - width: 50% - text-align: center \ No newline at end of file diff --git a/packages/ui-default/pages/problem_files.page.tsx b/packages/ui-default/pages/problem_files.page.tsx index 6a24ca760..e73f44761 100644 --- a/packages/ui-default/pages/problem_files.page.tsx +++ b/packages/ui-default/pages/problem_files.page.tsx @@ -1,324 +1,35 @@ import { STATUS } from '@hydrooj/common'; import $ from 'jquery'; -import { map } from 'lodash'; -import React from 'react'; -import ReactDOM from 'react-dom/client'; import FileSelectAutoComplete from 'vj/components/autocomplete/FileSelectAutoComplete'; -import { ActionDialog, confirm, InfoDialog } from 'vj/components/dialog/index'; +import { InfoDialog } from 'vj/components/dialog/index'; import createHint from 'vj/components/hint'; import Notification from 'vj/components/notification'; import { previewFile } from 'vj/components/preview/preview.page'; -import uploadFiles from 'vj/components/upload'; import download from 'vj/components/zipDownloader'; import { NamedPage } from 'vj/misc/Page'; import { i18n, pjax, request, tpl, } from 'vj/utils'; -async function downloadProblemFilesAsArchive(type, files) { - const { links, pdoc } = await request.post('', { operation: 'get_links', files, type }); - const targets = []; - for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] }); - await download(`${pdoc.docId} ${pdoc.title}.zip`, targets); +function ensureAndGetSelectedFiles(type = '') { + const allChecked = $(`.files tbody [data-checkbox-group="${type}"]:checked`); + const files = allChecked.get().map((i) => $(i).closest('tr').attr('data-filename')); + if (files.length === 0) { + Notification.error(i18n('Please select at least one file to perform this operation.')); + return null; + } + return files; } const page = new NamedPage('problem_files', () => { - function ensureAndGetSelectedFiles(type) { - const files = map( - $(`.problem-files-${type} tbody [data-checkbox-group="${type}"]:checked`), - (ch) => $(ch).closest('tr').attr('data-filename'), - ); - if (files.length === 0) { - Notification.error(i18n('Please select at least one file to perform this operation.')); - return null; - } - return files; - } - - async function handleClickUpload(type, files?) { - if (!files) { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - input.click(); - await new Promise((resolve) => { input.onchange = resolve; }); - files = input.files; - } - if (!files.length) { - Notification.warn(i18n('No file selected.')); - return; - } - await uploadFiles('', files, { type, pjax: true }); - } - - async function handleClickDownloadSelected(type) { - const selectedFiles = ensureAndGetSelectedFiles(type); - if (selectedFiles === null) return; - await downloadProblemFilesAsArchive(type, selectedFiles); - } - - async function handleClickRename(ev, type) { - const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; - // eslint-disable-next-line no-alert - const newName = prompt(i18n('Enter a new name for the file: ')); - if (!newName) return; - try { - await request.post('./files', { - operation: 'rename_files', - files: file, - newNames: [newName], - type, - }); - Notification.success(i18n('File have been renamed.')); - await pjax.request({ push: false }); - } catch (error) { - Notification.error(error.message); - } - } - - let prism = null; - const load = import('../components/highlighter/prismjs'); - load.then(({ default: p }) => { - prism = p.Prism; - }).catch(() => { }); - - async function handleClickRenameSelected(type) { - const selectedFiles = ensureAndGetSelectedFiles(type); - if (!selectedFiles?.length) return; - let onActionButton = (_: string) => false; - - function Rename(props) { - const [original, setOriginal] = React.useState(''); - const [replace, setReplace] = React.useState(''); - const [prefix, setPrefix] = React.useState(''); - const [suffix, setSuffix] = React.useState(''); - const [regexValid, setRegexValid] = React.useState(true); - const [wantNext, setWantNext] = React.useState(false); - const [preview, setPreview] = React.useState(false); - const [highlight, setHighlight] = React.useState(null); - const [newNames, setNewNames] = React.useState(props.names); - - React.useEffect(() => { - let s: string | RegExp = original; - setRegexValid(true); - setHighlight(null); - if (original.length > 2 && original.startsWith('/')) { - const availableFlags = ['g', 'i']; - const flags = []; - let copy = original.substring(1); - while (availableFlags.includes(copy[copy.length - 1])) { - flags.push(copy[copy.length - 1]); - copy = copy.substring(0, copy.length - 1); - } - if (copy.endsWith('/')) { - copy = copy.substring(0, copy.length - 1); - flags.reverse(); - if (prism) setHighlight(`/${prism.highlight(copy, prism.languages.regex, 'RegExp')}/${flags.join('')}`); - try { - s = new RegExp(copy, flags.join('')); - setRegexValid(true); - } catch (e) { - setRegexValid(false); - } - } - } - setNewNames(selectedFiles.map((file) => { - if (s) file = file.replace(s, replace); - return prefix + file + suffix; - })); - }, [original, replace, prefix, suffix]); - - onActionButton = (action) => { - if (action === 'ok') { - if (!preview) { - if (!regexValid) return false; - if (!original && !prefix && !suffix) { - setWantNext(true); - return false; - } - setPreview(true); - return false; - } - request.post('', { - operation: 'rename_files', - files: selectedFiles, - newNames, - type, - }).then(() => { - Notification.success(i18n('Selected files have been renamed.')); - pjax.request({ push: false }); - }).catch((error) => { - Notification.error(error.message); - }); - return true; - } - if (preview) { - setPreview(false); - return false; - } - return true; - }; - - const style = { fontFamily: 'var(--code-font-family)' }; - - return
- {!preview ? <> -
-
-

{i18n('Batch replacement')}

- - -
-
-

{i18n('Add prefix/suffix')}

- - -
-
-
-
-

{!regexValid ? i18n('Invalid RegExp') : wantNext ? i18n('No changes to make.') : i18n('RegExp supported, quote with "/"')}

-
-
- :
-

{i18n('Are you sure to rename the following file?')}

-
    - {original &&
  • Replace {original} with {replace}
  • } - {prefix &&
  • Add {prefix} as prefix
  • } - {suffix &&
  • Add {suffix} as suffix
  • } -
- - - - - - - - - - - - - {selectedFiles.map((file, index) => - - - )} - -
{i18n('Original filename(s)')}{i18n('New filename(s)')}
{file}{newNames[index]}
-
} -
; - } - - const promise = new ActionDialog({ - $body: tpl`
`, - width: '600px', - onDispatch(action) { - return onActionButton(action); - }, - }).open(); - const root = ReactDOM.createRoot(document.getElementById('rename_dialog')); - root.render(); - await promise; - root.unmount(); - } - - async function handleClickRemove(ev, type) { - const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; - if (!(await confirm(i18n('Confirm to delete the file?')))) return; - try { - await request.post('./files', { - operation: 'delete_files', - files: file, - type, - }); - Notification.success(i18n('File have been deleted.')); - await pjax.request({ push: false }); - } catch (error) { - Notification.error(error.message); - } - } - - async function handleClickRemoveSelected(type) { - const selectedFiles = ensureAndGetSelectedFiles(type); - if (selectedFiles === null) return; - if (!(await confirm(i18n('Confirm to delete the selected files?')))) return; - try { - await request.post('', { - operation: 'delete_files', - files: selectedFiles, - type, - }); - Notification.success(i18n('Selected files have been deleted.')); - await pjax.request({ push: false }); - } catch (error) { - Notification.error(error.message); - } - } - - function handleDragOver(type: string, ev: JQuery.DragOverEvent) { - ev.preventDefault(); - // TODO display a drag-drop allowed hint - } - - function handleDrop(type: string, e: JQuery.DropEvent) { - e.preventDefault(); - if (!$('[name="upload_testdata"]').length) { - Notification.error(i18n("You don't have permission to upload file.")); - return; - } - const ev = e.originalEvent; - const files = []; - if (ev.dataTransfer.items) { - for (let i = 0; i < ev.dataTransfer.items.length; i++) { - if (ev.dataTransfer.items[i].kind === 'file') { - const file = ev.dataTransfer.items[i].getAsFile(); - files.push(file); - } - } - } else { - for (let i = 0; i < ev.dataTransfer.files.length; i++) { - files.push(ev.dataTransfer.files[i]); - } - } - handleClickUpload(type, files); + async function handleClickDownloadSelected(ev) { + const type = $(ev.currentTarget).closest('[data-type]').attr('data-type'); + const files = ensureAndGetSelectedFiles(type); + if (files === null) return; + const { links, pdoc } = await request.post('', { operation: 'get_links', files, type }); + const targets = []; + for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] }); + await download(`${pdoc.docId} ${pdoc.title} ${type}.zip`, targets); } async function handleGenerateTestdata(ev) { @@ -372,27 +83,9 @@ const page = new NamedPage('problem_files', () => { FileSelectAutoComplete.getOrConstruct($('[name=std]'), { data: UiContext.pdoc.data }); $('[name="generate_testdata"]').on('click', handleGenerateTestdata); } - - if ($('[name="upload_testdata"]').length) { - $(document).on('click', '[name="upload_testdata"]', () => handleClickUpload('testdata')); - $(document).on('click', '[name="upload_file"]', () => handleClickUpload('additional_file')); - $(document).on('click', '[name="create_testdata"]', () => previewFile(undefined, 'testdata')); - $(document).on('click', '[name="create_file"]', () => previewFile(undefined, 'additional_file')); - $(document).on('click', '[name="testdata__rename"]', (ev) => handleClickRename(ev, 'testdata')); - $(document).on('click', '[name="additional_file__rename"]', (ev) => handleClickRename(ev, 'additional_file')); - $(document).on('click', '[name="rename_selected_testdata"]', () => handleClickRenameSelected('testdata')); - $(document).on('click', '[name="rename_selected_file"]', () => handleClickRenameSelected('additional_file')); - $(document).on('click', '[name="testdata__delete"]', (ev) => handleClickRemove(ev, 'testdata')); - $(document).on('click', '[name="additional_file__delete"]', (ev) => handleClickRemove(ev, 'additional_file')); - $(document).on('click', '[name="remove_selected_testdata"]', () => handleClickRemoveSelected('testdata')); - $(document).on('click', '[name="remove_selected_file"]', () => handleClickRemoveSelected('additional_file')); - } - $(document).on('dragover', '.problem-files-testdata', (ev) => handleDragOver('testdata', ev)); - $(document).on('dragover', '.problem-files-additional_file', (ev) => handleDragOver('additional_file', ev)); - $(document).on('drop', '.problem-files-testdata', (ev) => handleDrop('testdata', ev)); - $(document).on('drop', '.problem-files-additional_file', (ev) => handleDrop('additional_file', ev)); - $(document).on('click', '[name="download_selected_testdata"]', () => handleClickDownloadSelected('testdata')); - $(document).on('click', '[name="download_selected_file"]', () => handleClickDownloadSelected('additional_file')); + $(document).on('click', '[name="create_testdata"]', () => previewFile(undefined, 'testdata')); + $(document).on('click', '[name="create_file"]', () => previewFile(undefined, 'additional_file')); + $(document).on('click', '[name="download_selected"]', (ev) => handleClickDownloadSelected(ev)); $(document).on('vjContentNew', (e) => { createHint('Hint::icon::testdata', $(e.target).find('[name="create_testdata"]').get(0)?.parentNode?.parentNode?.children?.[0]); }); diff --git a/packages/ui-default/templates/contest_clarification.html b/packages/ui-default/templates/contest_clarification.html new file mode 100644 index 000000000..a9bbfb6e7 --- /dev/null +++ b/packages/ui-default/templates/contest_clarification.html @@ -0,0 +1,103 @@ +{% extends "layout/basic.html" %} +{% import "components/contest.html" as contest with context %} +{% import "components/problem.html" as problem with context %} +{% block content %} +{{ set(UiContext, 'tdoc', tdoc) }} +
+
+
+
+

{{ _('Contest Clarifications') }}

+ +
+
+ {% if not tcdocs.length %} + {{ nothing.render('Oh, there is no clarification!', compact=true) }} + {% else %} +
    + {% for doc in tcdocs %} +
  • +
    +
    +
    +
    + {{_('Subject') }}: {{ contest.render_clarification_subject(tdoc,pdict,doc.subject) }} | {% if doc.owner == 0 %}{{ _('Jury') }}{% else %}{{ user.render_inline(udict[doc.owner], badge=false) }}{% endif %} @ {{ datetimeSpan(doc._id)|safe }} +
    + {% if doc.owner %} + + {% endif %} +
    +
    + {{ doc['content']|markdown|safe }} +
    +
    +
    +
      + {%- for rdoc in doc['reply'] -%} +
    • +
      +
              
      +
      +
      +
      + {{ _('Jury') }} +
      +
      +
      + {{ rdoc['content']|markdown|safe }} +
      +
      +
      +
    • + {%- endfor -%} +
    +
  • + {% endfor %} +
+ {% endif %} +
+
+
+

{{ _('Send Broadcast Message') }}

+
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ {% include 'partials/contest_sidebar_management.html' %} +
+
+{% endblock %} diff --git a/packages/ui-default/templates/contest_detail.html b/packages/ui-default/templates/contest_detail.html index 3bfdd532f..db28e1cc3 100644 --- a/packages/ui-default/templates/contest_detail.html +++ b/packages/ui-default/templates/contest_detail.html @@ -38,11 +38,12 @@ {% endif %} - {% if tdoc.files.length %} + {% if files.length %}

{{ _('Files') }}

+ {% set filetype = 'private' %} {% include "partials/files.html" %}
{% endif %} diff --git a/packages/ui-default/templates/contest_edit.html b/packages/ui-default/templates/contest_edit.html index f50356be6..e5881d375 100644 --- a/packages/ui-default/templates/contest_edit.html +++ b/packages/ui-default/templates/contest_edit.html @@ -228,6 +228,20 @@
+ {% if tdoc %} +
+
+

{{ _('Files') }}

+
+ +
+
+ {% set sidebar = true %} + {% set files = tdoc.files %} + {% set filetype = "public" %} + {% include "partials/files.html" %} +
+ {% endif %} {% include 'components/md_hint.html' %}
diff --git a/packages/ui-default/templates/contest_manage.html b/packages/ui-default/templates/contest_manage.html index 4753a4184..4085abbe8 100644 --- a/packages/ui-default/templates/contest_manage.html +++ b/packages/ui-default/templates/contest_manage.html @@ -5,142 +5,69 @@ {{ set(UiContext, 'tdoc', tdoc) }}
-
-
-
-
- - - - - - - - - - - - - {%- for pid in tdoc.pids -%} - - - - - {%- endfor -%} - -
{{ _('Problem') }}{{ _('Score') }}
- - {{ problem.render_problem_title(pdict[pid], tdoc=tdoc, invalid=true, alphabetic=true) }} - - - {{ tdoc.score[pid]|default(100) }} -
-
-
+
+
+ + + + + + + + + + + + + + + {%- for pid in tdoc.pids -%} + + + + + + {%- endfor -%} + +
{{ _('ID') }}{{ _('Problem') }}{{ _('Score') }}
{{ pdict[pid].docId }} + + {{ problem.render_problem_title(pdict[pid], tdoc=tdoc, invalid=true, alphabetic=true) }} + + + {{ tdoc.score[pid]|default(100) }} +
-
-
+
+
+
+
-

{{ _('Files') }}

+

{{ _('Files') }}({{ _('Public') }})

{{ noscript_note.render() }} + {% set filetype = "public" %} {% include "partials/files.html" %}
-
-
+
+
-

{{ _('Contest Clarifications') }}

+

{{ _('Materials') }}({{ _('Only Partic.') }})

- {{ _('Send Broadcast Message') }} +
+ {% set files = tdoc.privateFiles %} + {% set filetype = "private" %} + {% include "partials/files.html" %}
- {% if not tcdocs.length %} - {{ nothing.render('Oh, there is no clarification!') }} - {% else %} -
    - {% for doc in tcdocs %} -
  • -
    -
    -
    -
    - {{_('Subject') }}: {{ contest.render_clarification_subject(tdoc,pdict,doc.subject) }} | {% if doc.owner == 0 %}{{ _('Jury') }}{% else %}{{ user.render_inline(udict[doc.owner], badge=false) }}{% endif %} @ {{ datetimeSpan(doc._id)|safe }} -
    - {% if doc.owner %} - - {% endif %} -
    -
    - {{ doc['content']|markdown|safe }} -
    -
    -
    -
      - {%- for rdoc in doc['reply'] -%} -
    • -
      -
              
      -
      -
      -
      - {{ _('Jury') }} -
      -
      -
      - {{ rdoc['content']|markdown|safe }} -
      -
      -
      -
    • - {%- endfor -%} -
    -
  • - {% endfor %} -
- {% endif %} -
-
-
-

{{ _('Send Broadcast Message') }}

-
-
-
-
- - -
-
- -
-
- -
-
- -
+
diff --git a/packages/ui-default/templates/contest_problemlist.html b/packages/ui-default/templates/contest_problemlist.html index 8f5b8e90f..6f27607a0 100644 --- a/packages/ui-default/templates/contest_problemlist.html +++ b/packages/ui-default/templates/contest_problemlist.html @@ -87,9 +87,9 @@
{% if not canViewRecord %} - {{ nothing.render('According to the contest rules, you cannot view your submission details at current.') }} + {{ nothing.render('According to the contest rules, you cannot view your submission details at current.', compact=true) }} {% elif not rdocs.length %} - {{ nothing.render('Oh, there is no submission!') }} + {{ nothing.render('Oh, there is no submission!', compact=true) }} {% else %} @@ -129,7 +129,7 @@
{% if not tcdocs.length %} - {{ nothing.render('Oh, there is no clarification!') }} + {{ nothing.render('Oh, there is no clarification!', compact=true) }} {% else %}
    {% for doc in tcdocs %} @@ -203,6 +203,20 @@
+ {% if tdoc.privateFiles.length %} +
+
+

{{_('Materials')}}

+
+
    + {% for file in tdoc.privateFiles %} + + {% endfor %} +
+
+ {% endif %} {% set owner_udoc = udict[tdoc.owner] %} {% include "partials/contest_sidebar.html" %}
diff --git a/packages/ui-default/templates/partials/contest_sidebar_management.html b/packages/ui-default/templates/partials/contest_sidebar_management.html index 1114449f3..815089268 100644 --- a/packages/ui-default/templates/partials/contest_sidebar_management.html +++ b/packages/ui-default/templates/partials/contest_sidebar_management.html @@ -29,6 +29,9 @@ + diff --git a/packages/ui-default/templates/partials/files.html b/packages/ui-default/templates/partials/files.html index 0cc6cdc09..3c54295e0 100644 --- a/packages/ui-default/templates/partials/files.html +++ b/packages/ui-default/templates/partials/files.html @@ -1,44 +1,49 @@ {% import "components/nothing.html" as nothing with context %} -
+
{% if files.length %}
- + {% if not sidebar %}{% endif %} + + {% if not sidebar %} + {% endif %} - + {%- for file in files -%} + {% if not sidebar %} + {% endif %} {%- endfor -%}
{{ _('Filename') }} {{ _('Size') }}
- {{ file.name }} + {{ file.name }} {{ size(file.size) }} - +
{% else %} - {{ nothing.render('There are no files currently.') }} + {{ nothing.render('There are no files currently.', compact=true) }} {% endif %}
\ No newline at end of file diff --git a/packages/ui-default/templates/partials/problem_files.html b/packages/ui-default/templates/partials/problem_files.html index 10fee0ba4..1ec89e5d9 100644 --- a/packages/ui-default/templates/partials/problem_files.html +++ b/packages/ui-default/templates/partials/problem_files.html @@ -1,7 +1,7 @@ {% import "components/nothing.html" as nothing with context %} {% set _files = testdata if filetype == 'testdata' else additional_file %} - {% if page_name == 'problem_edit' %} -
+
-

{{ additional_file.length|default(0) }} {{_('Additional File')}}

+

{{_('Additional Files')}}

+
+ +
- {% set sidebar = true %} {% set can_edit = true %} {% set filetype = "additional_file" %} diff --git a/packages/ui-default/templates/problem_files.html b/packages/ui-default/templates/problem_files.html index aecbda585..9374fea78 100644 --- a/packages/ui-default/templates/problem_files.html +++ b/packages/ui-default/templates/problem_files.html @@ -2,7 +2,7 @@ {% extends "layout/basic.html" %} {% block content %}
-
+

{{ _('Testdata') }}

@@ -10,7 +10,7 @@ {% if not reference and (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) %} {{ _('Config') }} - + {% elif reference %} {{ _('Source') }} {% endif %} @@ -22,10 +22,10 @@ {% include "partials/problem_files.html" %} {% if not reference and (handler.user.own(pdoc) or handler.user.hasPerm(perm.PERM_READ_PROBLEM_DATA) or handler.user.hasPriv(PRIV.PRIV_READ_PROBLEM_DATA)) %}
- + {% if handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) %} - - + + {% endif %}
{% endif %} @@ -61,14 +61,14 @@
{% endif %}
-
+
-

{{ _('Files') }}

+

{{ _('Additional Files') }}

{% if (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) and not reference %} - - + + {% endif %}
@@ -76,10 +76,10 @@ {% set can_edit = (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) and not reference %} {% include "partials/problem_files.html" %}
- + {% if (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) and not reference %} - - + + {% endif %}