diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e27d42048..71a9ce808 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,9 +25,13 @@ jobs: - name: Install run: yarn - name: Build - run: yarn build + run: | + yarn build + yarn build:ui:production - name: Lint - run: yarn lint:ci + run: | + yarn lint:ci + yarn lint:ui:ci - name: Unit Test run: yarn jest --runInBand - name: Report Coverage diff --git a/.gitignore b/.gitignore index f90e2cb8f..6254ccf70 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .build/ dist/ *.cache +.cache /build/*.js .cache/ *.log @@ -21,11 +22,17 @@ yarn.lock tsconfig.json # Project Rules +## Core packages/**/*.js !/build/build.js !**/bin/*.js !*.page.js !/install/jssh.d.ts +## ui-default +!packages/ui-default/**/*.js +packages/ui-default/public +packages/ui-default/misc/.iconfont +packages/ui-default/static/locale # Data files *.mmdb diff --git a/.gitpod.yml b/.gitpod.yml index 0a147cca8..7bb5f6500 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -5,3 +5,4 @@ vscode: extensions: - dbaeumer.vscode-eslint@2.1.3:1NRvj3UKNTNwmYjptmUmIw== - vscode-icons-team.vscode-icons@10.2.0:DrQUJPtFm0woSFiyitha8Q== + - samuelcolvin.jinjahtml@0.12.0:lFI6zKplznEZPdWvFvRHwg== diff --git a/.vscode/extensions.json b/.vscode/extensions.json index a41368142..772d96842 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,8 @@ { "recommendations": [ "dbaeumer.vscode-eslint", - "gruntfuggly.todo-tree" + "gruntfuggly.todo-tree", + "ronnidc.nunjucks", + "sysoev.language-stylus" ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 017efb1af..61477ff14 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,7 @@ "files.autoGuessEncoding": false, "files.encoding": "utf8", "files.eol": "\n", - "editor.detectIndentation": false, - "editor.tabSize": 4, + "editor.detectIndentation": true, "editor.formatOnSave": true, "editor.renderWhitespace": "boundary", "typescript.tsdk": "node_modules/typescript/lib" diff --git a/build/build.js b/build/build.js index 6d793cc33..912f93af2 100644 --- a/build/build.js +++ b/build/build.js @@ -49,6 +49,7 @@ const configFlat = { const packages = fs.readdirSync(path.resolve(process.cwd(), 'packages')); console.log(packages); for (const package of packages) { + if (package === 'ui-default') continue; const basedir = path.resolve(process.cwd(), 'packages', package); const files = fs.readdirSync(basedir); if (!files.includes('src') && !files.map(n => n.split('.')[1]).includes('ts')) continue; diff --git a/build/utils.ts b/build/utils.ts index c13dfff47..c14d099b0 100644 --- a/build/utils.ts +++ b/build/utils.ts @@ -15,7 +15,7 @@ export function getWorkspaces() { }); } -export type DependencyType = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' +export type DependencyType = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies'; export interface PackageJson extends Partial>> { name?: string diff --git a/package.json b/package.json index 1a50a66d6..544b901ed 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,14 @@ "scripts": { "build": "node build/build.js", "build:watch": "node build/build.js --watch", + "build:ui": "node packages/ui-default/build", + "build:ui:watch": "node packages/ui-default/build --watch", + "build:ui:production": "node packages/ui-default/build --production", "lint": "eslint packages --ext ts --fix", "lint:ci": "eslint packages --ext ts", + "lint:ui": "yarn run --cwd=packages/ui-default eslint . --fix", + "lint:ui:ci": "yarn run --cwd=packages/ui-default eslint .", + "snowpack": "cd packages/ui-default && snowpack dev", "pub": "node build/publish", "jest": "node build/jest", "debug": "node --async-stack-traces --trace-deprecation --enable-source-maps build/start --debug", diff --git a/packages/login-with-qq/package.json b/packages/login-with-qq/package.json index b428d3401..ab5ec2ff9 100644 --- a/packages/login-with-qq/package.json +++ b/packages/login-with-qq/package.json @@ -5,17 +5,10 @@ "repository": "git@github.com:hydro-dev/Hydro.git", "author": "undefined ", "license": "MIT", - "scripts": { - "lint": "eslint lib.ts service.ts --fix", - "build": "tsc" - }, - "peerDependencies": { - "hydrooj": "^2.15.0" - }, "dependencies": { "ws": "^7.4.4" }, "devDependencies": { - "@types/ws": "^7.4.0" + "@types/ws": "^7.4.1" } } \ No newline at end of file diff --git a/packages/ui-default/.eslintignore b/packages/ui-default/.eslintignore new file mode 100644 index 000000000..e4ee7a460 --- /dev/null +++ b/packages/ui-default/.eslintignore @@ -0,0 +1,4 @@ +node_modules +backendlib +build +public \ No newline at end of file diff --git a/packages/ui-default/.eslintrc.js b/packages/ui-default/.eslintrc.js new file mode 100644 index 000000000..e58123860 --- /dev/null +++ b/packages/ui-default/.eslintrc.js @@ -0,0 +1,113 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +const path = require('path'); + +module.exports = { + root: true, + parser: '@babel/eslint-parser', + plugins: ['babel', 'react'], + env: { + browser: true, + es6: true, + jquery: true, + commonjs: true, + }, + extends: ['airbnb'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 7, + ecmaFeatures: { + impliedStrict: true, + experimentalObjectRestSpread: true, + jsx: true, + legacyDecorators: true, + }, + babelOptions: { + configFile: './babel.config.js', + }, + }, + settings: { + 'import/resolver': { + webpack: { + config: { + resolve: { + extensions: ['.js', '.jsx'], + alias: { + vj: path.resolve(__dirname), + }, + }, + }, + }, + }, + }, + globals: { + __webpack_public_path__: true, + __webpack_require__: true, + UiContext: true, + UserContext: true, + LOCALES: true, + window: true, + }, + rules: { + // FIXME A bug with eslint-parser + 'template-curly-spacing': 'off', + indent: 'off', + + 'comma-dangle': [ + 'error', + 'always-multiline', + ], + // indent: [ + // 'error', + // 2, + // { + // SwitchCase: 0, + // }, + // ], + 'max-len': ['error', 150], + quotes: 'warn', + 'class-methods-use-this': 'off', + 'consistent-return': 'warn', + 'func-names': 'off', + 'import/first': 'off', + 'import/no-extraneous-dependencies': 'off', + 'max-classes-per-file': 'off', + 'no-unused-vars': 'warn', + 'no-console': 'off', + 'no-continue': 'off', + 'no-mixed-operators': 'off', + 'no-multi-assign': 'off', + 'no-plusplus': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: 'LabeledStatement', + message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', + }, + { + selector: 'WithStatement', + message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', + }, + ], + 'no-underscore-dangle': 'off', + 'no-await-in-loop': 'off', + 'no-lonely-if': 'off', + 'no-param-reassign': 'off', + 'no-bitwise': 'off', + 'react/prefer-stateless-function': 'off', + 'react/static-property-placement': 'off', + 'react/self-closing-comp': 'off', + 'react/prop-types': 'off', + 'react/jsx-filename-extension': 'off', + 'react/jsx-one-expression-per-line': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/no-string-refs': 'off', + 'react/require-default-props': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/destructuring-assignment': 'off', + 'react/button-has-type': 'off', + 'react/forbid-prop-types': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + }, +}; diff --git a/packages/ui-default/.npmignore b/packages/ui-default/.npmignore new file mode 100644 index 000000000..36d9cb756 --- /dev/null +++ b/packages/ui-default/.npmignore @@ -0,0 +1,11 @@ +node_modules/ +.cache/ +build/ +common/ +components/ +constant/ +misc/ +pages/ +static/ +utils/ +__* diff --git a/packages/ui-default/README.md b/packages/ui-default/README.md new file mode 100644 index 000000000..77a0b650a --- /dev/null +++ b/packages/ui-default/README.md @@ -0,0 +1,11 @@ +# Hydro-UI-Default +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhydro-dev%2Fui-default.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhydro-dev%2Fui-default?ref=badge_shield) + + +Based on Vijos-UI-Framework + +See [vijos/vj4/vj4/ui/README.md](https://github.com/vijos/vj4/blob/master/vj4/ui/README.md) + + +## License +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhydro-dev%2Fui-default.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhydro-dev%2Fui-default?ref=badge_large) \ No newline at end of file diff --git a/packages/ui-default/babel.config.js b/packages/ui-default/babel.config.js new file mode 100644 index 000000000..876787946 --- /dev/null +++ b/packages/ui-default/babel.config.js @@ -0,0 +1,45 @@ +module.exports = { + plugins: [ + 'lodash', + '@babel/plugin-transform-runtime', + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-syntax-import-meta', + '@babel/plugin-proposal-json-strings', + ['@babel/plugin-proposal-class-properties', { loose: true }], + '@babel/plugin-proposal-function-sent', + '@babel/plugin-proposal-export-namespace-from', + '@babel/plugin-proposal-numeric-separator', + '@babel/plugin-proposal-throw-expressions', + [ + 'prismjs', + { + languages: [ + 'clike', + 'c', + 'cpp', + 'pascal', + 'java', + 'python', + 'java', + 'python', + 'php', + 'rust', + 'haskell', + 'javascript', + 'go', + 'ruby', + 'csharp', + ], + plugins: [ + 'toolbar', + 'line-numbers', + ], + css: true, + }, + ], + ], + presets: [ + ['@babel/preset-env', { loose: true, modules: false }], + '@babel/preset-react', + ], +}; diff --git a/packages/ui-default/backendlib/markdown-it-katex.js b/packages/ui-default/backendlib/markdown-it-katex.js new file mode 100644 index 000000000..ca394fd28 --- /dev/null +++ b/packages/ui-default/backendlib/markdown-it-katex.js @@ -0,0 +1,136 @@ +// https://github.com/waylonflinn/markdown-it-katex/blob/master/index.js +const katex = require('katex'); + +const logger = new global.Hydro.Logger('katex'); + +function isValidDelim(state, pos) { + const max = state.posMax; + const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1; + const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1; + let canOpen = true; + let canClose = true; + if (prevChar === 0x09 + ||/* \t */ (nextChar >= 0x30/* "0" */ && nextChar <= 0x39/* "9" */)) canClose = false; + if (nextChar === 0x09/* \t */) canOpen = false; + return { + canOpen, + canClose, + }; +} + +function inline(state, silent) { + let pos; + if (state.src[state.pos] !== '$') return false; + let res = isValidDelim(state, state.pos); + if (!res.canOpen) { + if (!silent) state.pending += '$'; + state.pos += 1; + return true; + } + const start = state.pos + 1; + let match = start; + // eslint-disable-next-line no-cond-assign + while ((match = state.src.indexOf('$', match)) !== -1) { + pos = match - 1; + while (state.src[pos] === '\\') pos -= 1; + if ((match - pos) % 2) break; + match += 1; + } + if (match === -1) { + if (!silent) state.pending += '$'; + state.pos = start; + return true; + } + if (match - start === 0) { + if (!silent) state.pending += '$$'; + state.pos = start + 1; + return true; + } + res = isValidDelim(state, match); + if (!res.canClose) { + if (!silent) state.pending += '$'; + state.pos = start; + return true; + } + if (!silent) { + const token = state.push('math_inline', 'math', 0); + token.markup = '$'; + token.content = state.src.slice(start, match); + } + state.pos = match + 1; + return true; +} + +function block(state, start, end, silent) { + let lastLine; + let lastPos; + let found = false; + let pos = state.bMarks[start] + state.tShift[start]; + let max = state.eMarks[start]; + if (pos + 2 > max) return false; + if (state.src.slice(pos, pos + 2) !== '$$') return false; + pos += 2; + let firstLine = state.src.slice(pos, max); + if (silent) return true; + if (firstLine.trim().slice(-2) === '$$') { + firstLine = firstLine.trim().slice(0, -2); + found = true; + } + let next = start; + while (!found) { + next++; + if (next >= end) break; + pos = state.bMarks[next] + state.tShift[next]; + max = state.eMarks[next]; + if (pos < max && state.tShift[next] < state.blkIndent) break; + if (state.src.slice(pos, max).trim().slice(-2) === '$$') { + lastPos = state.src.slice(0, max).lastIndexOf('$$'); + lastLine = state.src.slice(pos, lastPos); + found = true; + } + } + state.line = next + 1; + const token = state.push('math_block', 'math', 0); + token.block = true; + token.content = (firstLine && firstLine.trim() ? `${firstLine}\n` : '') + + state.getLines(start + 1, next, state.tShift[start], true) + + (lastLine && lastLine.trim() ? lastLine : ''); + token.map = [start, state.line]; + token.markup = '$$'; + return true; +} + +module.exports = function plugin(md) { + const options = { throwOnError: false, strict: false }; + const katexInline = function (latex) { + options.displayMode = false; + try { + return katex.renderToString(latex, options); + } catch (error) { + if (options.throwOnError) logger.error(error); + return latex; + } + }; + const inlineRenderer = function (tokens, idx) { + return katexInline(tokens[idx].content); + }; + const katexBlock = function (latex) { + options.displayMode = true; + try { + return `

${katex.renderToString(latex, options)}

`; + } catch (error) { + if (options.throwOnError) logger.error(error); + return latex; + } + }; + + const blockRenderer = function (tokens, idx) { + return `${katexBlock(tokens[idx].content)}\n`; + }; + md.inline.ruler.after('escape', 'math_inline', inline); + md.block.ruler.after('blockquote', 'math_block', block, { + alt: ['paragraph', 'reference', 'blockquote', 'list'], + }); + md.renderer.rules.math_inline = inlineRenderer; + md.renderer.rules.math_block = blockRenderer; +}; diff --git a/packages/ui-default/backendlib/markdown-it-media.js b/packages/ui-default/backendlib/markdown-it-media.js new file mode 100644 index 000000000..27c0b3502 --- /dev/null +++ b/packages/ui-default/backendlib/markdown-it-media.js @@ -0,0 +1,161 @@ +/* eslint-disable max-len */ +/* eslint-disable prefer-destructuring */ +/* eslint-disable no-restricted-properties */ +const ytRegex = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/; +function youtubeParser(url) { + const match = url.match(ytRegex); + return match && match[7].length === 11 ? match[7] : url; +} +const vimeoRegex = /https?:\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|)(\d+)(?:$|\/|\?)/; +function vimeoParser(url) { + const match = url.match(vimeoRegex); + return match && typeof match[3] === 'string' ? match[3] : url; +} +const vineRegex = /^http(?:s?):\/\/(?:www\.)?vine\.co\/v\/([a-zA-Z0-9]{1,13}).*/; +function vineParser(url) { + const match = url.match(vineRegex); + return match && match[1].length === 11 ? match[1] : url; +} +const preziRegex = /^https:\/\/prezi.com\/(.[^/]+)/; +function preziParser(url) { + const match = url.match(preziRegex); + return match ? match[1] : url; +} +// TODO: Write regex for staging and local servers. +const mfrRegex = /^http(?:s?):\/\/(?:www\.)?mfr\.osf\.io\/render\?url=http(?:s?):\/\/osf\.io\/([a-zA-Z0-9]{1,5})\/\?action=download/; +function mfrParser(url) { + const match = url.match(mfrRegex); + return match ? match[1] : url; +} +const EMBED_REGEX = /@\[([a-zA-Z].+)]\((.*?)[)]/im; +function extractVideoParameters(url) { + const parameterMap = new Map(); + const params = url.replace(/&/gi, '&').split(/[#?&]/); + if (params.length > 1) { + for (let i = 1; i < params.length; i += 1) { + const keyValue = params[i].split('='); + if (keyValue.length > 1) parameterMap.set(keyValue[0], keyValue[1]); + } + } + return parameterMap; +} +function videoUrl(service, videoID, url, options) { + switch (service) { + case 'youtube': { + const parameters = extractVideoParameters(url); + if (options.youtube.parameters) { + Object.keys(options.youtube.parameters).forEach((key) => { + parameters.set(key, options.youtube.parameters[key]); + }); + } + const timeParameter = parameters.get('t'); + if (timeParameter !== undefined) { + let startTime = 0; + const timeParts = timeParameter.match(/[0-9]+/g); + let j = 0; + while (timeParts.length > 0) { + startTime += Number(timeParts.pop()) * Math.pow(60, j); + j += 1; + } + parameters.set('start', startTime); + parameters.delete('t'); + } + parameters.delete('v'); + parameters.delete('feature'); + parameters.delete('origin'); + const parameterArray = Array.from(parameters, (p) => p.join('=')); + const parameterPos = videoID.indexOf('?'); + let finalUrl = 'https://www.youtube'; + if (options.youtube.nocookie || url.indexOf('youtube-nocookie.com') > -1) finalUrl += '-nocookie'; + finalUrl += `.com/embed/${parameterPos > -1 ? videoID.substr(0, parameterPos) : videoID}`; + if (parameterArray.length > 0) finalUrl += `?${parameterArray.join('&')}`; + return finalUrl; + } + case 'vimeo': + return `https://player.vimeo.com/video/${videoID}`; + case 'vine': + return `https://vine.co/v/${videoID}/embed/${options.vine.embed}`; + case 'prezi': + return `https://prezi.com/embed/${videoID + }/?bgcolor=ffffff&lock_to_path=0&autoplay=0&autohide_ctrls=0&` + + 'landing_data=bHVZZmNaNDBIWnNjdEVENDRhZDFNZGNIUE43MHdLNWpsdFJLb2ZHanI5N1lQVHkxSHFxazZ0UUNCRHloSXZROHh3PT0&' + + 'landing_sign=1kD6c0N6aYpMUS0wxnQjxzSqZlEB8qNFdxtdjYhwSuI'; + case 'osf': + return `https://mfr.osf.io/render?url=https://osf.io/${videoID}/?action=download`; + default: + return service; + } +} + +module.exports = function videoPlugin(md) { + const options = { + url: videoUrl, + youtube: { width: 640, height: 390, nocookie: false }, + vimeo: { width: 500, height: 281 }, + vine: { width: 600, height: 600, embed: 'simple' }, + prezi: { width: 550, height: 400 }, + osf: { width: '100%', height: '100%' }, + pdf: videoUrl, + }; + md.renderer.rules.video = function tokenizeReturn(tokens, idx) { + const videoID = md.utils.escapeHtml(tokens[idx].videoID); + const service = md.utils.escapeHtml(tokens[idx].service).toLowerCase(); + const checkUrl = /http(?:s?):\/\/(?:www\.)?[a-zA-Z0-9-:.]{1,}\/render(?:\/)?[a-zA-Z0-9.&;?=:%]{1,}url=http(?:s?):\/\/[a-zA-Z0-9 -:.]{1,}\/[a-zA-Z0-9]{1,5}\/\?[a-zA-Z0-9.=:%]{1,}/; + let num; + if (service === 'osf' && videoID) { + num = Math.random() * 0x10000; + if (videoID.match(checkUrl)) { + return `
'; + } + return `
'; + } + if (service === 'pdf') return ``; + return videoID === '' ? '' + : `
`; + }; + md.inline.ruler.before('emphasis', 'video', (state, silent) => { + let token; + let videoID; + const theState = state; + const oldPos = state.pos; + if (state.src.charCodeAt(oldPos) !== 0x40 + ||/* @ */ state.src.charCodeAt(oldPos + 1) !== 0x5B/* [ */) { + return false; + } + const match = EMBED_REGEX.exec(state.src.slice(state.pos, state.src.length)); + if (!match || match.length < 3) return false; + const service = match[1]; + videoID = match[2]; + const serviceLower = service.toLowerCase(); + if (serviceLower === 'youtube') videoID = youtubeParser(videoID); + else if (serviceLower === 'vimeo') videoID = vimeoParser(videoID); + else if (serviceLower === 'vine') videoID = vineParser(videoID); + else if (serviceLower === 'prezi') videoID = preziParser(videoID); + else if (serviceLower === 'osf') videoID = mfrParser(videoID); + else if (!options[serviceLower]) return false; + if (videoID === ')') videoID = ''; + const serviceStart = oldPos + 2; + const serviceEnd = md.helpers.parseLinkLabel(state, oldPos + 1, false); + if (!silent) { + theState.pos = serviceStart; + theState.service = theState.src.slice(serviceStart, serviceEnd); + const newState = new theState.md.inline.State(service, theState.md, theState.env, []); + newState.md.inline.tokenize(newState); + token = theState.push('video', ''); + token.videoID = videoID; + token.service = service; + token.url = match[2]; + token.level = theState.level; + } + theState.pos += theState.src.indexOf(')', theState.pos); + return true; + }); +}; diff --git a/packages/ui-default/backendlib/markdown.js b/packages/ui-default/backendlib/markdown.js new file mode 100644 index 000000000..80a72db92 --- /dev/null +++ b/packages/ui-default/backendlib/markdown.js @@ -0,0 +1,73 @@ +const MarkdownIt = require('markdown-it'); +const Prism = require('prismjs'); + +// For math: $a\times b\eq 10$ +// Specific image size: ![image](image.png =100x100) +const Imsize = require('markdown-it-imsize'); +/* Footnote support. +Here is a footnote reference,[^1] and another.[^longnote] + +[^1]: Here is the footnote. + +[^longnote]: Here's one with multiple blocks. + + Subsequent paragraphs are indented to show that they +belong to the previous footnote. +*/ +const Footnote = require('markdown-it-footnote'); +// ==Highlight== +const Mark = require('markdown-it-mark'); +const TOC = require('markdown-it-table-of-contents'); +const Anchor = require('markdown-it-anchor'); +// @[url](videourl) +// @[youtube](https://youtube.com/watch?v=xxx) +// @[pdf](https://foo.com/bar.pdf) +const Media = require('./markdown-it-media'); +const Katex = require('./markdown-it-katex'); + +require('prismjs/components/index'); + +class Markdown extends MarkdownIt { + constructor() { + super({ + linkify: true, + highlight(str, lang) { + if (lang && Prism.languages[lang]) { + try { + return Prism.highlight(str, Prism.languages[lang], lang); + } catch (__) { } // eslint-disable-line no-empty + } + return ''; + }, + }); + this.linkify.tlds('.py', false); + this.use(Media); + this.use(Imsize); + this.use(Footnote); + this.use(Mark); + this.use(Anchor); + this.use(TOC); + this.use(Katex); + } +} + +const md = new Markdown(); + +function plugin(func, ...args) { + md.use(func, ...args); +} + +function render(text, html = false) { + md.set({ html: !!html }); + return md.render(text); +} + +function renderInline(text, html = false) { + md.set({ html: !!html }); + return md.renderInline(text); +} + +// eslint-disable-next-line no-multi-assign +global.Hydro.lib.markdown = module.exports = { + md, plugin, render, renderInline, +}; diff --git a/packages/ui-default/backendlib/template.js b/packages/ui-default/backendlib/template.js new file mode 100644 index 000000000..ecacfcc5e --- /dev/null +++ b/packages/ui-default/backendlib/template.js @@ -0,0 +1,206 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const serialize = require('serialize-javascript'); +const nunjucks = require('nunjucks'); +const { filter } = require('lodash'); +const { argv } = require('yargs'); +const Xss = require('xss'); +const markdown = require('./markdown'); + +const { misc, buildContent } = global.Hydro.lib; + +const xss = new Xss.FilterXSS({ + whiteList: { + a: ['target', 'href', 'title'], + abbr: ['title'], + address: [], + area: ['shape', 'coords', 'href', 'alt'], + article: [], + aside: [], + audio: ['autoplay', 'controls', 'loop', 'preload', 'src'], + b: [], + bdi: ['dir'], + bdo: ['dir'], + big: [], + blockquote: ['cite'], + br: [], + caption: [], + center: [], + cite: [], + code: [], + col: ['align', 'valign', 'span', 'width'], + colgroup: ['align', 'valign', 'span', 'width'], + dd: [], + del: ['datetime'], + details: ['open'], + div: [], + dl: [], + dt: [], + em: [], + font: ['color', 'size', 'face'], + h1: [], + h2: [], + h3: [], + h4: [], + h5: [], + h6: [], + header: [], + hr: [], + i: [], + img: ['src', 'alt', 'title', 'width', 'height'], + ins: ['datetime'], + li: [], + mark: [], + ol: [], + p: [], + pre: [], + s: [], + section: [], + small: [], + span: ['class'], + sub: [], + sup: [], + strong: [], + table: ['width', 'border', 'align', 'valign'], + tbody: ['align', 'valign'], + td: ['width', 'rowspan', 'colspan', 'align', 'valign'], + tfoot: ['align', 'valign'], + th: ['width', 'rowspan', 'colspan', 'align', 'valign'], + thead: ['align', 'valign'], + tr: ['rowspan', 'align', 'valign'], + tt: [], + u: [], + ul: [], + video: ['autoplay', 'controls', 'loop', 'preload', 'src', 'height', 'width'], + }, +}); + +class Loader extends nunjucks.Loader { + // eslint-disable-next-line class-methods-use-this + getSource(name) { + if (!argv.template) { + if (!global.Hydro.ui.template[name]) throw new Error(`Cannot get template ${name}`); + return { + src: global.Hydro.ui.template[name], + path: name, + noCache: false, + }; + } + let fullpath = null; + const p = path.resolve(process.cwd(), argv.template, name); + if (fs.existsSync(p)) fullpath = p; + if (!fullpath) throw new Error(`Cannot get template ${name}`); + return { + src: fs.readFileSync(fullpath, 'utf-8').toString(), + path: fullpath, + noCache: true, + }; + } +} + +class Nunjucks extends nunjucks.Environment { + constructor() { + super(new Loader(), { autoescape: true, trimBlocks: true }); + this.addFilter('json', (self) => JSON.stringify(self)); + this.addFilter('parseYaml', (self) => yaml.load(self)); + this.addFilter('dumpYaml', (self) => yaml.dump(self)); + this.addFilter('xss', (self) => xss.process(self)); + this.addFilter('serialize', (self, ignoreFunction = true) => serialize(self, { ignoreFunction })); + this.addFilter('assign', (self, data) => Object.assign(self, data)); + this.addFilter('markdown', (self, html = false) => markdown.render(self, html)); + this.addFilter('markdownInline', (self, html = false) => markdown.renderInline(self, html)); + this.addFilter('ansi', (self) => misc.ansiToHtml(self)); + this.addFilter('base64_encode', (s) => Buffer.from(s).toString('base64')); + this.addFilter('base64_decode', (s) => Buffer.from(s, 'base64').toString()); + this.addFilter('bitand', (self, val) => self & val); + this.addFilter('toString', (self) => (typeof self === 'string' ? self : JSON.stringify(self))); + this.addFilter('content', (content, language, html) => { + let s = ''; + try { + s = JSON.parse(content); + } catch { + s = content; + } + if (typeof s === 'object' && !(s instanceof Array)) { + if (s[language]) s = s[language]; + else s = s[Object.keys(s)[0]]; + } + if (s instanceof Array) s = buildContent(s, html ? 'html' : 'markdown', (str) => str.translate(language)); + return html ? xss.process(s) : markdown.render(s); + }); + this.addFilter('log', (self) => { + console.log(self); + return self; + }); + } +} +nunjucks.runtime.memberLookup = function memberLookup(obj, val) { + if ((obj || {})._original) obj = obj._original; + if (obj === undefined || obj === null) return undefined; + if (typeof obj[val] === 'function') { + const fn = function () { + // eslint-disable-next-line + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + // eslint-disable-next-line prefer-rest-params + args[_key2] = arguments[_key2]; + } + // eslint-disable-next-line block-scoped-var + return obj[val].call(obj, ...args); + }; + fn._original = obj[val]; + return fn; + } + return obj[val]; +}; +const env = new Nunjucks(); +env.addGlobal('static_url', (assetName) => { + const cdnPrefix = global.Hydro.model.system.get('server.cdn'); + if (global.Hydro.ui.manifest[assetName]) { + return `${cdnPrefix}${global.Hydro.ui.manifest[assetName]}`; + } + return `${cdnPrefix}${assetName}`; +}); +// eslint-disable-next-line no-eval +env.addGlobal('eval', eval); +env.addGlobal('typeof', (o) => typeof o); +env.addGlobal('datetimeSpan', misc.datetimeSpan); +env.addGlobal('paginate', misc.paginate); +env.addGlobal('size', misc.size); +env.addGlobal('gravatar', misc.gravatar); +env.addGlobal('model', global.Hydro.model); +env.addGlobal('ui', global.Hydro.ui); +env.addGlobal('isIE', (str) => str.includes('MSIE') || str.includes('rv:11.0')); +env.addGlobal('set', (obj, key, val) => { + if (val !== undefined) obj[key] = val; + else obj = { ...obj, key }; + return ''; +}); +env.addGlobal('findSubModule', (prefix) => { + filter(Object.keys(global.Hydro.ui.template), (n) => n.startsWith(prefix)); +}); + +async function render(name, state) { + // eslint-disable-next-line no-return-await + return await new Promise((resolve, reject) => { + env.render(name, { + page_name: name.split('.')[0], + ...state, + formatJudgeTexts: (texts) => texts.map((text) => { + if (typeof text === 'string') return text; + return state._(text.message).format(...text.params || []) + ((argv.debug && text.stack) ? `\n${text.stack}` : ''); + }).join('\n'), + perm: global.Hydro.model.builtin.PERM, + PRIV: global.Hydro.model.builtin.PRIV, + STATUS: global.Hydro.model.builtin.STATUS, + UiContext: state.handler.UiContext, + }, (err, res) => { + if (err) reject(err); + else resolve(res); + }); + }); +} + +module.exports = render; + +global.Hydro.lib.template = { render }; diff --git a/packages/ui-default/breakpoints.json b/packages/ui-default/breakpoints.json new file mode 100644 index 000000000..58aba8756 --- /dev/null +++ b/packages/ui-default/breakpoints.json @@ -0,0 +1,5 @@ +{ + "mobile": 600, + "desktop": 1000, + "hd": 1250 +} diff --git a/packages/ui-default/build/config/gulp.js b/packages/ui-default/build/config/gulp.js new file mode 100644 index 000000000..1377577e1 --- /dev/null +++ b/packages/ui-default/build/config/gulp.js @@ -0,0 +1,82 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable guard-for-in */ +/* eslint-disable import/no-extraneous-dependencies */ +import _ from 'lodash'; +import moment from 'moment'; +import gulp from 'gulp'; +import log from 'fancy-log'; +import chalk from 'chalk'; +import svgmin from 'gulp-svgmin'; +import vinylBuffer from 'vinyl-buffer'; +import iconfont from 'gulp-iconfont'; +import plumber from 'gulp-plumber'; +import gulpif from 'gulp-if'; +import nunjucks from '../plugins/gulpNunjucks'; +import vjTouch from '../plugins/gulpTouch'; + +let isInWatchMode = false; +export const tasks = {}; +const iconTimestamp = moment.utc([2020, 0, 1, 0, 0, 0, 0]).unix(); + +function handleWatchChange(name, r = 300) { + return _.debounce((ev) => { + log('File %s: %s', chalk.yellow(ev.type), ev.path); + tasks[name](); + }, r); +} + +function offsetMtimeAtFirstBuild() { + // Offset the mtime at first build to + // workaround webpack/watchpack issue #25. + return gulpif(!isInWatchMode, vjTouch(~~((Date.now() - 30 * 1000) / 1000))); +} + +export default function ({ errorHandler }) { + let iconfontTemplateArgs = null; + + tasks['iconfont:template'] = () => gulp + .src('misc/icons/template/*.styl') + .pipe(nunjucks(iconfontTemplateArgs)) + .pipe(gulp.dest('misc/.iconfont')) + .pipe(offsetMtimeAtFirstBuild()); + + tasks.iconfont = () => gulp + .src('misc/icons/*.svg') + .pipe(plumber({ errorHandler })) + .pipe(svgmin()) + .pipe(gulp.dest('misc/icons')) + .pipe(offsetMtimeAtFirstBuild()) + .pipe(iconfont({ + fontHeight: 1000, + prependUnicode: false, + descent: 6.25 / 100 * 1000, + fontName: 'vj4icon', + formats: ['svg', 'ttf', 'eot', 'woff', 'woff2'], + timestamp: iconTimestamp, + })) + .on('glyphs', (glyphs, options) => { + iconfontTemplateArgs = { glyphs, options }; + tasks['iconfont:template'](); + }) + .pipe(gulp.dest('misc/.iconfont')) + .pipe(vinylBuffer()) + .pipe(offsetMtimeAtFirstBuild()); + + tasks.watch = () => { + isInWatchMode = true; + gulp.watch('misc/icons/*.svg', handleWatchChange('iconfont')); + }; + + for (const key in tasks) { + // gulp4 uses function name directly as task name + Object.defineProperty(tasks[key], 'name', { + value: key, + configurable: true, + }); + tasks[key] = gulp.series(tasks[key]); + } + + tasks.default = gulp.series(gulp.parallel(tasks.iconfont)); + + return tasks; +} diff --git a/packages/ui-default/build/config/webpack.js b/packages/ui-default/build/config/webpack.js new file mode 100644 index 000000000..5c3f5d97a --- /dev/null +++ b/packages/ui-default/build/config/webpack.js @@ -0,0 +1,177 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { dirname } from 'path'; +import webpack from 'webpack'; +import ExtractCssPlugin from 'mini-css-extract-plugin'; +import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; +import FriendlyErrorsPlugin from 'friendly-errors-webpack-plugin'; +import OptimizeCssAssetsPlugin from 'optimize-css-assets-webpack-plugin'; +import CopyWebpackPlugin from 'copy-webpack-plugin'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import SpeedMeasurePlugin from 'speed-measure-webpack-plugin'; +import mapWebpackUrlPrefix from '../utils/mapWebpackUrlPrefix'; +import StaticManifestPlugin from '../plugins/manifest.webpack'; +import root from '../utils/root'; + +const beautifyOutputUrl = mapWebpackUrlPrefix([ + { prefix: 'node_modules/katex/dist/', replace: './katex/' }, + { prefix: 'misc/.iconfont', replace: './ui/iconfont' }, +]); +const smp = new SpeedMeasurePlugin(); + +export default function (env = {}) { + function babelLoader() { + return { + loader: 'babel-loader', + options: { + ...require(root('babel.config.js')), // eslint-disable-line + cacheDirectory: true, + }, + }; + } + + function cssLoader() { + return { + loader: 'css-loader', + options: { importLoaders: 1 }, + }; + } + + function postcssLoader() { + return { + loader: 'postcss-loader', + options: { sourceMap: env.production, config: { path: root('postcss.config.js') } }, + }; + } + + function fileLoader() { + return { + loader: 'file-loader', + options: { name: '[path][name].[ext]?[sha1:hash:hex:10]' }, + }; + } + + function extractCssLoader() { + return { + loader: ExtractCssPlugin.loader, + // FIXME auto? + options: { + publicPath: '', + }, + }; + } + + const config = { + bail: true, + mode: env.production ? 'production' : 'development', + profile: true, + context: root(), + entry: { + hydro: './entry.js', + 'theme.default': './theme/default.js', + }, + output: { + path: root('public'), + publicPath: '/', // overwrite in entry.js + hashFunction: 'sha1', + hashDigest: 'hex', + hashDigestLength: 10, + filename: '[name].js?[chunkhash]', + chunkFilename: '[name].chunk.js?[chunkhash]', + }, + resolve: { + modules: [root('node_modules'), root('../../node_modules')], + extensions: ['.js', '.jsx'], + alias: { vj: root() }, + }, + module: { + rules: [ + { + test: /\.(svg|ttf|eot|woff|woff2|png|jpg|jpeg|gif)$/, + use: [fileLoader()], + }, + { + test: /\.jsx?$/, + exclude: /node_modules[/\\]/, + use: [babelLoader()], + }, + { + test: /\.styl$/, + use: [extractCssLoader(), cssLoader(), postcssLoader(), 'stylus-loader'], + }, + { + test: /\.css$/, + use: [extractCssLoader(), cssLoader(), postcssLoader()], + }, + ], + }, + optimization: { + splitChunks: { + minChunks: 2, + }, + }, + plugins: [ + new webpack.ProgressPlugin(), + new webpack.ProvidePlugin({ + $: 'jquery', + jQuery: 'jquery', + 'window.jQuery': 'jquery', + katex: 'katex/dist/katex.js', + React: 'react', + }), + new ExtractCssPlugin({ + filename: '[name].css?[contenthash:10]', + }), + new webpack.LoaderOptionsPlugin({ + test: /\.styl$/, + stylus: { + default: { + preferPathResolver: 'webpack', + use: [require('rupture')()], // eslint-disable-line global-require + import: ['~vj/common/common.inc.styl'], + }, + }, + }), + new FriendlyErrorsPlugin(), + new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), + new CopyWebpackPlugin({ + patterns: [ + { from: root('static') }, + { from: root(dirname(require.resolve('emojify.js/package.json')) + '/dist/images/basic'), to: 'img/emoji/' }, + ], + }), + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: env.production ? '"production"' : '"debug"', + }, + }), + ...env.production + ? [ + new OptimizeCssAssetsPlugin(), + new webpack.optimize.ModuleConcatenationPlugin(), + new webpack.LoaderOptionsPlugin({ minimize: true }), + new webpack.HashedModuleIdsPlugin(), + ] + : [new webpack.NamedModulesPlugin()], + new webpack.LoaderOptionsPlugin({ + options: { + context: root(), + customInterpolateName: (url) => beautifyOutputUrl(url), + }, + }), + new MonacoWebpackPlugin({ + // available options are documented at https://github.com/Microsoft/monaco-editor-webpack-plugin#options + languages: ['cpp', 'csharp', 'java', 'javascript', 'python', 'rust', 'ruby', 'php', 'pascal', 'go'], + }), + new StaticManifestPlugin({ + fileName: 'static-manifest.json', + ignore: [ + 'img/emoji/', + 'katex/', + ], + }), + ...env.measure ? [new BundleAnalyzerPlugin({ analyzerPort: 'auto' })] : [], + ], + }; + + return env.measure ? smp.wrap(config) : config; +} diff --git a/packages/ui-default/build/index.js b/packages/ui-default/build/index.js new file mode 100644 index 000000000..7e38fb2e7 --- /dev/null +++ b/packages/ui-default/build/index.js @@ -0,0 +1,6 @@ +require('@babel/register')({ + "plugins": ["@babel/plugin-transform-runtime"], + "presets": [["@babel/preset-env", { "loose": true }]] +}); +const main = require('./main.js'); +if (!module.parent) main(); diff --git a/packages/ui-default/build/main.js b/packages/ui-default/build/main.js new file mode 100644 index 000000000..d5ac2c81e --- /dev/null +++ b/packages/ui-default/build/main.js @@ -0,0 +1,62 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { argv } from 'yargs'; +import webpack from 'webpack'; +import gulp from 'gulp'; +import log from 'fancy-log'; +import chalk from 'chalk'; +import root from './utils/root'; +import gulpConfig from './config/gulp'; +import webpackConfig from './config/webpack'; + +function runWebpack({ watch, production, measure }) { + const compiler = webpack(webpackConfig({ watch, production, measure })); + return new Promise((resolve, reject) => { + function compilerCallback(err, stats) { + if (err) { + console.error(err.stack || err); + if (err.details) console.error(err.details); + reject(err); + } + if (argv.detail) console.log(stats.toString()); + if (!watch && (!stats || stats.hasErrors())) process.exitCode = 1; + resolve(); + } + if (watch) compiler.watch({}, compilerCallback); + else compiler.run(compilerCallback); + }); +} + +async function runGulp() { + function handleError(err) { + log(chalk.red('Error: %s'), chalk.reset(err.toString() + err.stack)); + if (err) process.exit(1); + } + const gulpTasks = gulpConfig({ production: true, errorHandler: handleError }); + return new Promise((resolve) => { + const taskList = {}; + + gulp.on('start', ({ uid, name }) => { + log(chalk.blue('Starting task: %s'), chalk.reset(name)); + taskList[uid] = true; + }); + gulp.on('stop', ({ uid, name }) => { + log(chalk.green('Finished: %s'), chalk.reset(name)); + taskList[uid] = false; + + if (Object.values(taskList).filter((b) => b).length === 0) { + resolve(); + } + }); + gulpTasks.default(); + }); +} + +async function main() { + const dir = process.cwd(); + process.chdir(root()); + await runGulp({}); + await runWebpack(argv); + process.chdir(dir); +} + +module.exports = main; diff --git a/packages/ui-default/build/plugins/gulpNunjucks.js b/packages/ui-default/build/plugins/gulpNunjucks.js new file mode 100644 index 000000000..dffa94c00 --- /dev/null +++ b/packages/ui-default/build/plugins/gulpNunjucks.js @@ -0,0 +1,27 @@ +import through from 'through2'; +import nunjucks from 'nunjucks'; + +export default function compile(data, options = {}) { + return through.obj(function (file, encoding, callback) { + if (file.isNull()) { + callback(null, file); + return; + } + const context = { ...data, ...file.data }; + const filePath = file.path; + const env = options.env || new nunjucks.Environment(new nunjucks.FileSystemLoader(file.base), options); + if (options.filters && !options.env) { + for (const key of Object.keys(options.filters)) { + env.addFilter(key, options.filters[key]); + } + } + try { + file.contents = Buffer.from(env.renderString(file.contents.toString(), context)); + file.extname = '.styl'; + this.push(file); + } catch (error) { + this.emit('error', new Error(`gulp-nunjucks ${error} ${filePath}`)); + } + callback(); + }); +} diff --git a/packages/ui-default/build/plugins/gulpTouch.js b/packages/ui-default/build/plugins/gulpTouch.js new file mode 100644 index 000000000..c1ab2d1b6 --- /dev/null +++ b/packages/ui-default/build/plugins/gulpTouch.js @@ -0,0 +1,35 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import PluginError from 'plugin-error'; +import through from 'through2'; +import fs from 'fs-extra'; + +export default function touch(mtime) { + async function touchFile(file) { + const fd = await fs.open(file.path, 'a'); + try { + await fs.futimes(fd, mtime, mtime); + } finally { + await fs.close(fd); + } + } + + function processStream(file, encoding, callback) { + if (file.isNull()) { + callback(); + return; + } + if (file.isStream()) { + this.emit('error', new PluginError('gulpTouch', 'Stream not supported')); + callback(); + return; + } + touchFile(file) + .catch((err) => this.emit('error', err)) + .then(() => { + this.push(file); + callback(); + }); + } + + return through.obj(processStream); +} diff --git a/packages/ui-default/build/plugins/manifest.webpack.js b/packages/ui-default/build/plugins/manifest.webpack.js new file mode 100644 index 000000000..90252ef6d --- /dev/null +++ b/packages/ui-default/build/plugins/manifest.webpack.js @@ -0,0 +1,49 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const _ = require('lodash'); +const crypto = require('crypto'); + +export default class StaticManifestPlugin { + constructor({ fileName, ignore }) { + this.fileName = fileName; + this.ignore = ignore; + } + + apply(compiler) { + compiler.hooks.emit.tap('sync', (compilation) => { + const stats = compilation.getStats().toJson(); + const manifest = _(stats.assets) + .map((asset) => { + const { name } = asset; + // Skip files listed in ignore + if (_.some(this.ignore, (ignorePattern) => name.indexOf(ignorePattern) > -1)) { + return null; + } + // Skip calculating hash for names like ?xxxx + if (name.indexOf('?') > -1) { + return [ + name.replace(/\?[\s\S]*/, ''), // origin name + name, // name with hash + ]; + } + // We need to calculate hash for the remaining files. + const source = compilation.assets[asset.name].source(); + const shasum = crypto.createHash('sha1'); + shasum.update(source); + const hash = shasum.digest('hex').substr(0, 10); + return [ + name, // origin name + `${name}?${hash}`, // name with hash + ]; + }) + .filter() + .orderBy(['0']) + .fromPairs() + .value(); + const json = JSON.stringify(manifest, null, 2); + compilation.assets[this.fileName] = { + source: () => json, + size: () => json.length, + }; + }); + } +} diff --git a/packages/ui-default/build/plugins/provide.snowpack.js b/packages/ui-default/build/plugins/provide.snowpack.js new file mode 100644 index 000000000..3742f3221 --- /dev/null +++ b/packages/ui-default/build/plugins/provide.snowpack.js @@ -0,0 +1,23 @@ +module.exports = function () { + return { + name: 'provide-plugin', + async transform({ id, fileExt, contents }) { + if (fileExt !== '.js' && fileExt !== '.jsx') return contents; + if (id.includes('/node_modules/jquery.transit/')) { + return `import jQuery from 'jquery'\n${contents.replace('this, function', '{jQuery}, function')}`; + } + if (id.includes('/node_modules/sticky-kit/')) { + return `import jQuery from 'jquery'\n${contents.replace('$ = this.jQuery || window.jQuery;', '$ = jQuery;')}`; + } + if (id.includes('/node_modules/')) { + if (!/\/(jquery-scroll-lock|jquery\.easing|jquery\.transit)\//.test(id)) return { contents }; + } + if (contents.includes('$(')) contents = `import $ from 'jquery';\n${contents}`; + if (contents.includes('jQuery')) contents = `import jQuery from 'jquery';\n${contents}`; + if (!contents.includes('import React')) { + if (contents.includes('React.') || fileExt === '.jsx') contents = `import React from 'react';\n${contents}`; + } + return contents.replace(/process\.env/g, 'import.meta.env'); + }, + }; +}; diff --git a/packages/ui-default/build/plugins/require-context.snowpack.js b/packages/ui-default/build/plugins/require-context.snowpack.js new file mode 100644 index 000000000..fb8e093b1 --- /dev/null +++ b/packages/ui-default/build/plugins/require-context.snowpack.js @@ -0,0 +1,216 @@ +const acorn = require('acorn'); +const stage3 = require('acorn-stage3'); +const walk = require('acorn-walk'); +const astring = require('astring'); +const path = require('path'); +const fsp = require('fs').promises; + +const dirWatchers = {}; + +const parser = acorn.Parser.extend(stage3); +async function requireContext(filePath) { + const source = await fsp.readFile(filePath, 'utf8'); + if (!/\brequire\s*\.\s*context\s*\(/.test(source)) { + delete dirWatchers[filePath]; + return null; + } + const ast = parser.parse(source, + { sourceType: 'module', ecmaVersion: 'latest', locations: true }); + const base = path.dirname(path.resolve(filePath)); + const nodes = []; + walk.simple(ast, { + CallExpression(node) { + const { callee } = node; + const args = node.arguments; + if (callee.type !== 'MemberExpression') return; + if (callee.object.name !== 'require') return; + if (callee.property.name !== 'context') return; + if (args.length === 0) return; + if (!args.every((arg) => arg.type === 'Literal')) return; + if (args.length > 2 && !args[2].regex) return; + nodes.push(node); + }, + }); + if (nodes.length === 0) { + delete dirWatchers[filePath]; + return null; + } + const imports = []; + const dirs = []; + await Promise.all(nodes.map(async (node) => { + const args = node.arguments; + const directory = path.resolve(base, args[0].value); + const recurse = args[1] && args[1].value; + const regExp = args[2] && new RegExp(args[2].regex.pattern, args[2].regex.flags); + dirs.push(directory); + async function getFiles(dir, recurse, pattern) { + try { + const dirents = await fsp.readdir(dir, { withFileTypes: true }); + const files = await Promise.all(dirents.map((dirent) => { + const res = path.resolve(dir, dirent.name); + if (dirent.isDirectory()) { + return (recurse !== false) && getFiles(res); + } + return (!pattern || pattern.test(res)) ? res : null; + })); + return Array.prototype.concat(...files); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } + } + const files = (await getFiles(directory, recurse, regExp)) + .filter((file) => file).map((file) => { + file = path.relative(base, file); + if (!file.startsWith('/') && !file.startsWith('.')) file = `./${file}`; + return file; + }); + const modules = files.map((file) => Math.random().toString(36).slice(2).replace(/\d/g, '') + file.split('/').pop().split('.')[0] + .replace(/^\w/, (c) => c.toUpperCase()) + .replace(/[-_]\w/g, (c) => c[1].toUpperCase()).replace(/\W/g, '$')); + files.forEach((file, i) => { + imports.push({ + type: 'ImportDeclaration', + specifiers: [ + { + type: 'ImportDefaultSpecifier', + local: { type: 'Identifier', name: modules[i] }, + }, + ], + source: { type: 'Literal', value: file, raw: JSON.stringify(file) }, + }); + }); + const keys = files.map((file) => `./${path.relative(directory, path.resolve(base, file))}`); + const contextKeys = { + type: 'ArrayExpression', + elements: keys.map((file) => ( + { type: 'Literal', value: file, raw: JSON.stringify(file) } + )), + }; + const contextMap = { + type: 'ObjectExpression', + properties: keys.map((key, i) => ({ + type: 'Property', + method: false, + shorthand: false, + computed: false, + key: { type: 'Literal', value: key, raw: JSON.stringify(key) }, + value: { + type: 'ObjectExpression', + properties: [{ + type: 'Property', + method: false, + shorthand: false, + computed: false, + key: { type: 'Identifier', name: 'default' }, + value: { type: 'Identifier', name: modules[i] }, + kind: 'init', + }], + }, + kind: 'init', + })), + }; + const contextFn = { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'context' }, + init: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [{ type: 'Identifier', name: 'id' }], + body: { + type: 'MemberExpression', + object: contextMap, + property: { type: 'Identifier', name: 'id' }, + computed: true, + optional: false, + }, + }, + }, + ], + kind: 'let', + }; + const keyFn = { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'context' }, + property: { type: 'Identifier', name: 'keys' }, + computed: false, + optional: false, + }, + right: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [], + body: contextKeys, + }, + }, + }; + const contextExpr = { + type: 'CallExpression', + callee: { + type: 'ArrowFunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [ + contextFn, + keyFn, + { + type: 'ReturnStatement', + argument: { + type: 'Identifier', + name: 'context', + }, + }, + ], + }, + }, + arguments: [], + optional: false, + }; + delete node.callee; + delete node.arguments; + delete node.optional; + Object.assign(node, contextExpr); + })); + ast.body.unshift(...imports); + dirWatchers[filePath] = dirs; + return astring.generate(ast); +} + +module.exports = function (snowpackConfig, pluginOptions) { + return { + name: 'require-context-plugin', + resolve: { + input: Array.from(pluginOptions.input || ['.js']), + output: ['.js'], + }, + onChange({ filePath }) { + for (const [source, dirs] of Object.entries(dirWatchers)) { + if (dirs.some((dir) => filePath.startsWith(dir))) { + this.markChanged(source); + } + } + }, + load({ filePath }) { + return requireContext(filePath); + }, + }; +}; diff --git a/packages/ui-default/build/utils/mapWebpackUrlPrefix.js b/packages/ui-default/build/utils/mapWebpackUrlPrefix.js new file mode 100644 index 000000000..9b8343b65 --- /dev/null +++ b/packages/ui-default/build/utils/mapWebpackUrlPrefix.js @@ -0,0 +1,18 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import _ from 'lodash'; + +export default function mapWebpackUrlPrefix(mapList) { + const rules = mapList.map((mappingRule) => { + const regex = mappingRule.prefix.split('/').map((s) => _.escapeRegExp(s)).join('[\\/\\\\]'); + return { + from: new RegExp(`^${regex}`, mappingRule.flag), + to: mappingRule.replace, + }; + }); + return function mapUrl(url) { + rules.forEach((rule) => { + url = url.replace(rule.from, rule.to); + }); + return url; + }; +} diff --git a/packages/ui-default/build/utils/root.js b/packages/ui-default/build/utils/root.js new file mode 100644 index 000000000..b402e6142 --- /dev/null +++ b/packages/ui-default/build/utils/root.js @@ -0,0 +1,6 @@ +/* eslint-disable */ +import path from 'path'; + +export default function root(fn = '.') { + return path.resolve(__dirname, '../../', fn); +} diff --git a/packages/ui-default/build/ver.js b/packages/ui-default/build/ver.js new file mode 100644 index 000000000..a630e555c --- /dev/null +++ b/packages/ui-default/build/ver.js @@ -0,0 +1,8 @@ +const { writeFileSync } = require('fs'); +const { argv } = require('yargs'); +const pkg = require('../package.json'); + +if (argv.dev) pkg.version = `${pkg.version}-dev`; +else pkg.version = pkg.version.replace('-dev', ''); + +writeFileSync(`${process.cwd()}/package.json`, JSON.stringify(pkg, null, 2)); diff --git a/packages/ui-default/common/color.inc.styl b/packages/ui-default/common/color.inc.styl new file mode 100644 index 000000000..defc99594 --- /dev/null +++ b/packages/ui-default/common/color.inc.styl @@ -0,0 +1,17 @@ +aqua = #7FDBFF +blue = #0074D9 +navy = #001F3F +teal = #39CCCC +green = #2ECC40 +olive = #3D9970 +lime = #01FF70 +yellow = #FFDC00 +orange = #FF851B +red = #FF4136 +fuchsia = #F012BE +purple = #B10DC9 +maroon = #85144B +white = #ffffff +silver = #dddddd +gray = #aaaaaa +black = #111111 diff --git a/packages/ui-default/common/common.inc.styl b/packages/ui-default/common/common.inc.styl new file mode 100644 index 000000000..ff72825e0 --- /dev/null +++ b/packages/ui-default/common/common.inc.styl @@ -0,0 +1,12 @@ +// This file is imported in all stylus source code +breakpoints = json('../breakpoints.json', { hash: true }) +rupture.mobile-cutoff = unit(breakpoints.mobile, 'px') +rupture.desktop-cutoff = unit(breakpoints.desktop, 'px') +rupture.hd-cutoff = unit(breakpoints.hd, 'px') + +@import '~vj/misc/.iconfont/webicon.inc.styl' +@import 'color.inc.styl' +@import 'variables.inc.styl' +@import 'easing.inc.styl' +@import 'rem.inc.styl' +@import 'functions.inc.styl' diff --git a/packages/ui-default/common/easing.inc.styl b/packages/ui-default/common/easing.inc.styl new file mode 100644 index 000000000..50713e02c --- /dev/null +++ b/packages/ui-default/common/easing.inc.styl @@ -0,0 +1,39 @@ +// Cubic +ease-in-cubic = cubic-bezier(0.550, 0.055, 0.675, 0.190); +ease-out-cubic = cubic-bezier(0.215, 0.610, 0.355, 1.000); +ease-in-out-cubic = cubic-bezier(0.645, 0.045, 0.355, 1.000); + +// Circ +ease-in-circ = cubic-bezier(0.600, 0.040, 0.980, 0.335); +ease-out-circ = cubic-bezier(0.075, 0.820, 0.165, 1.000); +ease-in-out-circ = cubic-bezier(0.785, 0.135, 0.150, 0.860); + +// Expo +ease-in-expo = cubic-bezier(0.950, 0.050, 0.795, 0.035); +ease-out-expo = cubic-bezier(0.190, 1.000, 0.220, 1.000); +ease-in-out-expo = cubic-bezier(1.000, 0.000, 0.000, 1.000); + +// Quad +ease-in-quad = cubic-bezier(0.550, 0.085, 0.680, 0.530); +ease-out-quad = cubic-bezier(0.250, 0.460, 0.450, 0.940); +ease-in-out-quad = cubic-bezier(0.455, 0.030, 0.515, 0.955); + +// Quart +ease-in-quart = cubic-bezier(0.895, 0.030, 0.685, 0.220); +ease-out-quart = cubic-bezier(0.165, 0.840, 0.440, 1.000); +ease-in-out-quart = cubic-bezier(0.770, 0.000, 0.175, 1.000); + +// Quint +ease-in-quint = cubic-bezier(0.755, 0.050, 0.855, 0.060); +ease-out-quint = cubic-bezier(0.230, 1.000, 0.320, 1.000); +ease-in-out-quint = cubic-bezier(0.860, 0.000, 0.070, 1.000); + +// Sine +ease-in-sine = cubic-bezier(0.470, 0.000, 0.745, 0.715); +ease-out-sine = cubic-bezier(0.390, 0.575, 0.565, 1.000); +ease-in-out-sine = cubic-bezier(0.445, 0.050, 0.550, 0.950); + +// Back +ease-in-back = cubic-bezier(0.600, -0.280, 0.735, 0.045); +ease-out-back = cubic-bezier(0.175, 0.885, 0.320, 1.275); +ease-in-out-back = cubic-bezier(0.680, -0.550, 0.265, 1.550); diff --git a/packages/ui-default/common/functions.inc.styl b/packages/ui-default/common/functions.inc.styl new file mode 100644 index 000000000..778ac974a --- /dev/null +++ b/packages/ui-default/common/functions.inc.styl @@ -0,0 +1,9 @@ +word-wrap() + overflow-wrap: break-word + word-wrap: break-word + -ms-word-break: break-all + word-break: break-word + -ms-hyphens: auto + -moz-hyphens: auto + -webkit-hyphens: auto + hyphens: auto diff --git a/packages/ui-default/common/rem.inc.styl b/packages/ui-default/common/rem.inc.styl new file mode 100644 index 000000000..b80603429 --- /dev/null +++ b/packages/ui-default/common/rem.inc.styl @@ -0,0 +1,7 @@ +rem() { + ret = (); + for arg in arguments { + push(ret, unit(arg / $font-size, 'rem')); + } + return ret; +} diff --git a/packages/ui-default/common/variables.inc.styl b/packages/ui-default/common/variables.inc.styl new file mode 100644 index 000000000..26887d177 --- /dev/null +++ b/packages/ui-default/common/variables.inc.styl @@ -0,0 +1,257 @@ +// layout +$grid-padding = 15px // half gutter + +// fonts +$primary-font-family = "Open Sans", "Seravek", "Segoe UI", "Verdana", "PingFang SC", "Hiragino Sans GB", "Lantinghei SC", "Microsoft Yahei", "WenQuanYi Micro Hei", "sans" +$header-font-family = "Open Sans", "Seravek", "Segoe UI", "Verdana", "PingFang SC", "Lantinghei SC", "Microsoft Yahei", "Hiragino Sans GB", "Microsoft Sans Serif", "WenQuanYi Micro Hei", "sans-serif" +$code-font-family = "monaco", "Source Code Pro", "Consolas", "Lucida Console", "monospace" + +// primary palette +$default-color = #ddd +$primary-color = #5f9fd6 +$secondary-color = #ed5f82 +$highlight-color = #df6589 +$code-color = #c92a2a +$text-1-color = #555 +$text-2-color = lighten($text-1-color, 15%) +$text-3-color = lighten($text-1-color, 30%) +$header-1-color = #3d3d3d +$header-2-color = lighten($header-1-color, 20%) +$blockquote-color = #999 +$page-bg-color = #edf0f2 +$content-bg-color = #fff +$border-1-color = $default-color +$border-2-color = lighten($default-color, 20%) + +$immersive-primary-color = #ffef87 +$immersive-text-color = #f8f8f8 +$immersive-header-color = #ffffff + +$success-color = #25ad40 +$fail-color = #fb5555 +$progress-color = #f39800 +$invalid-color = #9fa0a0 + +// sizes +$font-size = 16px // main content +$font-size-secondary = 14px // secondary content +$font-size-small = 13px // additional informations, usually gray colored +$font-size-icon = 16px +$font-size-title = 20px + +// components +$section-gap-v = 25px +$section-gap-h = $grid-padding +$section-margin = 30px +$section-shadow = rem(0 6px 22px) rgba(#AFC2C9, 0.5) +$section-bg-color = $content-bg-color +$section-title-color = $header-2-color +$section-toolbtn-color = #AAA +$section-toolbtn-color-hover = #444 + +$autocomplete-empty-row-color = #AAA +$userselect-uid-color = #BBB + +$editor-border-color = $border-1-color + +$datepicker-shadow = rem(0 2px 7px) rgba(#000, 0.3) +$datapicker-list-item-bg-color-hover = #F0F0F0 + +$dialog-layer-bg-color = rgba(#F0F0F0, 0.6) +$dialog-bg-color = $content-bg-color +$dialog-border-color = $border-1-color +$dialog-shadow = rem(0 2px 10px) rgba(#000, 0.2) +$dialog-title-color = $header-2-color + +$comment-placeholder-color = #AAA +$comment-op-color = lighten($text-1-color, 30%) +$comment-op-link-color = lighten($text-1-color, 50%) + +$menu-item-bg-color-hover = #F4F4F4 +$menu-drop-shadow = rem(0 2px 7px) rgba(#000, 0.3) +$menu-drop-bg-color = $content-bg-color +$menu-drop-triangle-size = 4px +$menu-drop-triangle-shadow = rem(-2px -2px 2px) rgba(#000, 0.15) + +$nav-item-height = 45px +$nav-item-hover-color = $primary-color +$nav-item-active-color = $secondary-color +$nav-item-active-immersive-color = #9AE3F3 +$nav-item-round-height = 32px +$nav-item-round-color = $primary-color +$nav-item-round-border = 2px +$nav-logo-width = 45px +$nav-logo-height = 23px + +$header-layer-height = 100px +$header-bg-width = 1920px +$header-bg-height = 320px +$header-bg-height-mini = $header-bg-height - $header-layer-height +//$header-logo-width = 280px +//$header-logo-height = 150px +//$header-logo-width-real = 280px +//$header-logo-height-real = 150px +$header-logo-width = 252px +$header-logo-height = 150px +$header-logo-width-real = 381px +$header-logo-height-real = 201px +$header-logo-domain-lh = 60px +$header-logo-system-lh = 30px + +$footer-bg-color = $content-bg-color + +$input-focus-color = #C1E0FF +$form-control-height = 35px +$compact-control-height = 28px +$input-margin = 0 0 1rem +$input-multi-line-margin = 0 0.3rem 0.15rem 0 +$input-image-radio-margin = 0 0.3rem 0.3rem 0 +$input-background-color = $content-bg-color +$input-border = 1px solid $border-1-color +$input-outline = 2px solid transparent +$input-focus-border-color = $primary-color +$input-focus-outline = 2px solid $input-focus-color +$label-color = lighten($text-1-color, 10%) +$help-text-color = lighten($text-1-color, 30%) + +$table-border-color = $border-2-color +$table-alternate-bg-color = #F4F4F4 +$table-header-bg-color = rgba(#FFF, 0.95) +$table-header-shadow = rem(0 3px 2px) rgba(#000, 0.03) +$table-header-shadow-floating = rem(0 2px 5px) rgba(#000, 0.2) +$table-padding-h = 10px +$table-lh = 26px + +$toolbar-bg = #f2f6f7 +$toolbar-fore = #8395a1 +$toolbar-fore-hover = #5a6b75 +$toolbar-bg-hover = #e3e7e8 +$toolbar-fore-selected = #FFF +$toolbar-bg-selected = $secondary-color +$toolbar-disabled-opacity = 0.3 + +$supplementary-color = lighten($text-1-color, 50%) +$supplementary-border-color = lighten($supplementary-color, 62%) +$supplementary-link-color = lighten($text-1-color, 40%) + +$pager-border-color = $table-border-color + +$nprogress-color = #FFF +$nprogress-color-floating = $primary-color + +$syntax-hl-bg-color = #F8F8F8 + +// Calendar +$calendar-banner-color = { + '0': #FA6582, + '1': #EEA9C5, + '2': #FDB24D, + '3': #98CA86, + '4': #7DCBBF, + '5': #20BDE8, + '6': #6BCDDB, + '7': #6598FF, + '8': #8EB0F5, + '9': #928FE3, + '10': #676E7E, + '11': #A1A5AB, +} + +// Notification +$notification-color = { + 'success': #9BDB7B, + 'info': #78D6F4, + 'warn': #FCD279, + 'error': #FD848D, +} + +$notification-icon = { + 'success': $icon-check--circle, + 'info': $icon-info--circle, + 'warn': $icon-info--circle, + 'error': $icon-close--circle, +} + +// Badge +$badge-text-color = { + 'mod': #FFF, + 'lv0': #727171, + 'lv1': #FFF, + 'lv2': #FFF, + 'lv3': #FFF, + 'lv4': #FFF, + 'lv5': #FFF, + 'lv6': #FFF, + 'lv7': #FFF, + 'lv8': #FFF, + 'lv9': #FFF, + 'lv10': #FFF, +} +$badge-bg-color = { + 'mod': #cf70ab, + 'lv0': #efefef, + 'lv1': #9db4bd, + 'lv2': #9db4bd, + 'lv3': #73c3bc, + 'lv4': #73c3bc, + 'lv5': #81ba6c, + 'lv6': #81ba6c, + 'lv7': #f7b200, + 'lv8': #ff9251, + 'lv9': #eb6363, + 'lv10': #575757, +} + +// Record Status +$record-status-color = { + 'pass': $success-color, + 'fail': $fail-color, + 'progress': $progress-color, + 'ignored': $invalid-color, + 'pending': $invalid-color, +} + +$record-status-icon = { + 'pass': $icon-check, + 'fail': $icon-close, + 'progress': $icon-hourglass, + 'ignored': $icon-close, + 'pending': $icon-schedule, +} + +// Training Status +$training-status-color = { + 'outside': $supplementary-color, + 'progress': #e64c72, + 'done': #25ad40, +} + +$training-status-icon = { + 'outside': $icon-help2, + 'progress': $icon-schedule, + 'done': $icon-check, +} + +// Training Section Status +$training-section-status-color = { + 'done': #25ad40, + 'open': #e64c72, + 'progress': #e64c72, + 'invalid': $supplementary-color, +} + +$training-section-status-icon = { + 'done': $icon-check, + 'open': $icon-help2, + 'progress': $icon-schedule, + 'invalid': $icon-close, +} + +// Contest type +// #6EA2C7, #48AAB7, #6BB67A, #F5C735, #DC7D86 +$contest-color = { + 'acm': #6BB67A, + 'oi': #F5C735, + 'ioi': #2E9AFE, + 'homework': #FFCDCD, +} diff --git a/packages/ui-default/components/DOMAttachedObject.js b/packages/ui-default/components/DOMAttachedObject.js new file mode 100644 index 000000000..59e381c14 --- /dev/null +++ b/packages/ui-default/components/DOMAttachedObject.js @@ -0,0 +1,125 @@ +let removalChecker = null; +const res = []; + +function checkResources() { + for (let i = res.length - 1; i >= 0; --i) { + if (res[i].detached || !document.body.contains(res[i].$dom[0])) { + if (!res[i].detached) res[i].detach(); + res.splice(i, 1); + } + } + if (res.length === 0) { + clearInterval(removalChecker); + removalChecker = null; + } +} + +function monitorResource(resource) { + res.push(resource); + if (removalChecker === null) { + removalChecker = setInterval(checkResources, 500); + } +} + +export default class DOMAttachedObject { + static uniqueIdCounter = 0; + + static get($obj) { + const key = this.DOMAttachKey; + return $obj.data(key); + } + + static getOrConstruct($obj, ...args) { + const $singleObj = $obj.eq(0); + const key = this.DOMAttachKey; + const Protoclass = this; + const instance = this.get($singleObj); + if (instance !== undefined) return instance; + const newInstance = new Protoclass($singleObj, ...args); + // $dom may be updated in constructor so that we use $dom instead + // of $singleObj here. + if (!newInstance.$dom) return null; + newInstance.$dom.data(key, newInstance); + return newInstance; + } + + static attachAll(container = document.body, ...args) { + if (process.env.NODE_ENV !== 'production') { + if (!this.DOMAttachSelector) { + // eslint-disable-next-line quotes + throw new Error(`'DOMAttachSelector' should be specified`); + } + } + if (process.env.NODE_ENV !== 'production') { + console.time(`DOMAttachedObject[${this.DOMAttachKey}]: attachAll`); + } + $(container) + .find(this.DOMAttachSelector) + .addBack(this.DOMAttachSelector) + .get() + .forEach((element) => this.getOrConstruct($(element), ...args)); + if (process.env.NODE_ENV !== 'production') { + console.timeEnd(`DOMAttachedObject[${this.DOMAttachKey}]: attachAll`); + } + } + + static detachAll(container = document.body) { + const selector = this.DOMDetachSelector || this.DOMAttachSelector; + if (process.env.NODE_ENV !== 'production') { + if (!selector) { + // eslint-disable-next-line quotes + throw new Error(`'DOMDetachSelector' or 'DOMAttachSelector' should be specified`); + } + } + if (process.env.NODE_ENV !== 'production') { + console.time(`DOMAttachedObject[${this.DOMAttachKey}]: detachAll`); + } + $(container) + .find(selector) + .addBack(selector) + .get() + .forEach((element) => { + const instance = this.get($(element)); + if (instance) { + instance.detach(); + } + }); + if (process.env.NODE_ENV !== 'production') { + console.timeEnd(`DOMAttachedObject[${this.DOMAttachKey}]: detachAll`); + } + } + + static registerLifeCycleHooks(attach = true) { + if (process.env.NODE_ENV !== 'production') { + if (!this.DOMAttachSelector) { + // eslint-disable-next-line quotes + throw new Error(`'DOMAttachSelector' should be specified`); + } + } + $(document).on('vjContentNew', (e) => this.attachAll(e.target)); + $(document).on('vjContentRemove', (e) => this.detachAll(e.target)); + if (attach) { + this.attachAll(); + } + } + + detach() { + if (this.constructor.DOMAttachKey) { + this.$dom.removeData(this.constructor.DOMAttachKey); + } + this.detached = true; + } + + constructor($dom, monitorDetach = false) { + if ($dom == null) { + return null; + } + this.$dom = $dom; + this.id = ++DOMAttachedObject.uniqueIdCounter; + this.eventNS = `vj4obj_${this.id}`; + this.detached = false; + if (monitorDetach) { + monitorResource(this); + } + } +} diff --git a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.js b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.js new file mode 100644 index 000000000..a26e3bd09 --- /dev/null +++ b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.js @@ -0,0 +1,44 @@ +import _ from 'lodash'; +import tpl from 'vj/utils/tpl'; +import request from 'vj/utils/request'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import AutoComplete from '.'; + +function getText(domain) { + return domain._id; +} + +function getItems(val) { + return request.get('/domain/search', { q: val }); +} + +function renderItem(domain) { + return tpl` +
+
+ +
+
+
${domain.name}
+
ID = ${domain._id}
+
+
+ `; +} + +export default class DomainSelectAutoComplete extends AutoComplete { + static DOMAttachKey = 'vjDomainSelectAutoCompleteInstance'; + + constructor($dom, options) { + super($dom, { + classes: 'domain-select', + items: getItems, + render: renderItem, + text: getText, + ...options, + }); + } +} + +_.assign(DomainSelectAutoComplete, DOMAttachedObject); +window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.js b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.js new file mode 100644 index 000000000..a0897f33e --- /dev/null +++ b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.js @@ -0,0 +1,44 @@ +import _ from 'lodash'; +import tpl from 'vj/utils/tpl'; +import request from 'vj/utils/request'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import AutoComplete from '.'; + +function getText(user) { + return user.uname; +} + +function getItems(val) { + return request.get('/user/search', { q: val }); +} + +function renderItem(user) { + return tpl` +
+
+ +
+
+
${user.uname}
+
UID = ${user._id}
+
+
+ `; +} + +export default class UserSelectAutoComplete extends AutoComplete { + static DOMAttachKey = 'vjUserSelectAutoCompleteInstance'; + + constructor($dom, options) { + super($dom, { + classes: 'user-select', + items: getItems, + render: renderItem, + text: getText, + ...options, + }); + } +} + +_.assign(UserSelectAutoComplete, DOMAttachedObject); +window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/autocomplete.page.styl b/packages/ui-default/components/autocomplete/autocomplete.page.styl new file mode 100644 index 000000000..cd631147f --- /dev/null +++ b/packages/ui-default/components/autocomplete/autocomplete.page.styl @@ -0,0 +1,11 @@ +.drop-element.autocomplete + max-width: rem(250px) + + .empty-row + color: $autocomplete-empty-row-color + font-size: $(font-size-small) + padding: rem(10px) + + .menu + max-height: rem(250px) + overflow: auto diff --git a/packages/ui-default/components/autocomplete/domainselectautocomplete.page.styl b/packages/ui-default/components/autocomplete/domainselectautocomplete.page.styl new file mode 100644 index 000000000..bcd2a6f24 --- /dev/null +++ b/packages/ui-default/components/autocomplete/domainselectautocomplete.page.styl @@ -0,0 +1,6 @@ +.menu.domain-select + width: rem(200px) + +.domain-select__id + font-size: rem($font-size-small) + color: $userselect-uid-color diff --git a/packages/ui-default/components/autocomplete/index.js b/packages/ui-default/components/autocomplete/index.js new file mode 100644 index 000000000..c9ca90a0e --- /dev/null +++ b/packages/ui-default/components/autocomplete/index.js @@ -0,0 +1,203 @@ +import Drop from 'tether-drop'; +import _ from 'lodash'; +import 'jquery-scroll-lock'; + +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; + +import i18n from 'vj/utils/i18n'; +import tpl from 'vj/utils/tpl'; +import zIndexManager from 'vj/utils/zIndexManager'; + +export default class AutoComplete extends DOMAttachedObject { + static DOMAttachKey = 'vjAutoCompleteInstance'; + + constructor($dom, options = {}) { + super($dom); + this.options = { + items: async () => [], + render: () => '', + text: () => null, + minChar: 1, + cache: true, + clearDefaultValue: true, + position: 'bottom left', + classes: '', + ...options, + }; + this.clear(this.options.clearDefaultValue); + this.menuShown = false; + this.cache = {}; + this.currentItems = []; + this.$menu = $(``); + this.$menu.scrollLock({ strict: false }); + this.$menu.on('mousedown', this.onMenuClick.bind(this)); + this.$menu.on('mousedown', '.menu__item', this.onItemClick.bind(this)); + this.dropInstance = new Drop({ + classes: 'autocomplete dropdown', + target: this.$dom[0], + content: this.$menu[0], + position: this.options.position, + constrainToWindow: false, + constrainToScrollParent: false, + openOn: false, + }); + this.dropInstance.on('open', this.onDropOpen.bind(this)); + this.attach(); + } + + clear(clearValue = true) { + if (clearValue) { + this.$dom.val(''); + } + this._value = null; + this.lastText = null; + } + + attach() { + this.$dom.on(`click.${this.eventNS}`, this.onClick.bind(this)); + this.$dom.on(`focus.${this.eventNS}`, this.onFocus.bind(this)); + this.$dom.on(`blur.${this.eventNS}`, this.onBlur.bind(this)); + this.$dom.on(`keydown.${this.eventNS}`, this.onKeyDown.bind(this)); + this.$dom.on(`keyup.${this.eventNS}`, this.onKeyUp.bind(this)); + } + + onClick() { + this.updateOpenState(); + } + + onFocus() { + this.isFocus = true; + this.updateOpenState(); + } + + onBlur() { + this.isFocus = false; + this.updateOpenState(); + } + + onKeyDown() { + // TODO: Implement keyboard navigation + } + + onKeyUp(ev) { + if (ev.which === 27) { + // ESC + this.close(); + return; + } + if (this.$dom.val() === this.lastText) { + return; + } + this.lastText = this.$dom.val(); + this.updateOpenState(); + if (this.isOpen) { + this.renderList(); + } + } + + onMenuClick(ev) { + ev.preventDefault(); // prevent from losing focus + } + + onItemClick(ev) { + const idx = $(ev.currentTarget).attr('data-idx'); + if (idx === undefined) { + return; + } + const item = this.currentItems[idx]; + const text = this.options.text(item); + this._value = item; + this.$dom.trigger('vjAutoCompleteSelect', item); + if (text != null) { + this.$dom.val(text); + } + this.close(); + } + + onDropOpen() { + $(this.dropInstance.drop).css('z-index', zIndexManager.getNext()); + } + + async getItems(val) { + if (this.cache[val] !== undefined) { + return this.cache[val]; + } + const data = await this.options.items(val); + if (this.options.cache) { + this.cache[val] = data; + } + return data; + } + + getHtml(items) { + if (items.length === 0) { + return tpl`
${i18n('Oops, there are no results.')}
`; + } + return items.map((item, idx) => ` + + `).join('\n'); + } + + async renderList() { + const val = this.$dom.val(); + const items = await this.getItems(val); + const html = this.getHtml(items); + this.currentItems = items; + this.$menu.html(html); + } + + open() { + if (this.isOpen) { + return; + } + this.dropInstance.open(); + this.isOpen = true; + } + + close() { + if (!this.isOpen) { + return; + } + this.dropInstance.close(); + this.isOpen = false; + } + + updateOpenState() { + if (this.isFocus && this.$dom.val().length >= this.options.minChar) { + if (!this.isOpen) { + this.open(); + this.renderList(); + } + } else { + if (this.isOpen) { + this.close(); + } + } + } + + detach() { + if (this.detached) { + return; + } + super.detach(); + this.$dom.off(`focus.${this.eventNS}`); + this.$dom.off(`blur.${this.eventNS}`); + this.$dom.off(`keydown.${this.eventNS}`); + this.$dom.off(`keyup.${this.eventNS}`); + this.dropInstance.destroy(); + this.$menu.remove(); + } + + value() { + return this._value; + } + + focus() { + this.$dom.focus(); + } +} + +_.assign(AutoComplete, DOMAttachedObject); +window.Hydro.components.autocomplete = AutoComplete; diff --git a/packages/ui-default/components/autocomplete/userselectautocomplete.page.styl b/packages/ui-default/components/autocomplete/userselectautocomplete.page.styl new file mode 100644 index 000000000..2c0329d57 --- /dev/null +++ b/packages/ui-default/components/autocomplete/userselectautocomplete.page.styl @@ -0,0 +1,6 @@ +.menu.user-select + width: rem(200px) + +.user-select__uid + font-size: rem($font-size-small) + color: $userselect-uid-color diff --git a/packages/ui-default/components/calendar/calendar.page.styl b/packages/ui-default/components/calendar/calendar.page.styl new file mode 100644 index 000000000..7606ad085 --- /dev/null +++ b/packages/ui-default/components/calendar/calendar.page.styl @@ -0,0 +1,129 @@ +.calendar__header + line-height: rem(45px) + text-align: center + + h1 + display: inline-block + line-height: inherit + width: rem(280px) + + button + font-size: rem($font-size-title) + color: $text-3-color + padding: 0 rem(10px) + outline: 0 + + &:hover + color: $primary-color + +.calendar__week-title + table + width: 100% + + th + width: 14.28% + padding: rem(20px 10px) + color: $supplementary-color + +.calendar__row + position: relative + +.calendar__row__bg + position: absolute + left: 0 + top: 0 + width: 100% + height: 100% + + table + width: 100% + height: 100% + + td + width: 14.28% + border: 1px solid $table-border-color + + &.is-current-day + background: lighten($highlight-color, 85%) + +.calendar__row__content + min-height: rem(100px) + position: relative + z-index: 2 + padding-bottom: rem(10px) + + table + width: 100% + + th + width: 14.28% + padding: rem(10px) + color: $text-2-color + + &.is-inactive + opacity: 0.3 + + &.is-current-day + color: $highlight-color + + td + position: relative + height: rem(24px) + +.calendar__banner + position: absolute + display: block + top: rem(2px) + left: rem(5px) + right: rem(5px) + height: rem(20px) + line-height: rem(20px) + padding: rem(0 5px) + font-size: rem($font-size-small) + border-radius: rem(5px) + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + + &, &:visited, &:hover, &:active + color: #FFF + text-decoration: none + + &.is-trunc-begin, &.is-snap-begin + border-top-left-radius: 0 + border-bottom-left-radius: 0 + + &.is-trunc-end, &.is-snap-end + border-top-right-radius: 0 + border-bottom-right-radius: 0 + + &.is-snap-begin + left: 0 + padding-left: rem(10px) + + &.is-snap-end + right: 0 + padding-right: rem(10px) + + &.is-masked + background-image: repeating-linear-gradient( + 45deg, + rgba(#000, 0.1), + rgba(#000, 0.1) 10px, + rgba(#000, 0) 10px, + rgba(#000, 0) 20px + ); + + for key, value in $calendar-banner-color + &.color-{key} + background-color: value + +.calendar__body-container + position: relative + +.calendar__body.exit + position: absolute + left: 0 + top: 0 + width: 100% + user-select: none diff --git a/packages/ui-default/components/calendar/index.js b/packages/ui-default/components/calendar/index.js new file mode 100644 index 000000000..9fde50323 --- /dev/null +++ b/packages/ui-default/components/calendar/index.js @@ -0,0 +1,367 @@ +import _ from 'lodash'; +import moment from 'moment'; +import tpl from 'vj/utils/tpl'; + +export default class Calendar { + constructor(events) { + this.$dom = $(tpl` +
+
+ +

+ +
+
+ + + + + + + + + + +
SUNMONTUEWEDTHUFRISAT
+
+
+
+ `); + this.events = events.map((ev) => ({ + ...ev, + beginAt: moment(ev.beginAt), + endAt: moment(ev.endAt), + maskFrom: ev.maskFrom ? moment(ev.maskFrom) : null, + })); + this.$dom.find('[name="prev"]').click(() => this.navToPrev()); + this.$dom.find('[name="next"]').click(() => this.navToNext()); + this.$lastBody = null; + this.navToToday(); + } + + getDom() { + return this.$dom; + } + + navToToday() { + if (this.animating) { + return; + } + this.current = moment().date(1); + this.update(); + } + + navToNext() { + if (this.animating) { + return; + } + this.current.add('months', 1); + this.update(1); + } + + navToPrev() { + if (this.animating) { + return; + } + this.current.subtract('months', 1); + this.update(-1); + } + + update(direction = 0) { + this.updateHeader(); + this.updateBody(direction); + } + + updateHeader() { + this.$dom.find('.calendar__header__title').text(this.current.format('MMMM YYYY')); + } + + async updateBody(direction) { + this.animating = true; + const $newBody = this.buildBody(); + $newBody.appendTo(this.$dom.find('.calendar__body-container')); + if (this.$lastBody !== null) { + this.$lastBody + .addClass('exit') + .transition({ + y: direction * 100, + opacity: 0, + }, { + duration: 500, + easing: 'easeOutCubic', + }); + await $newBody + .css({ + y: -direction * 100, + opacity: 0, + }) + .transition({ + y: 0, + opacity: 1, + }, { + duration: 500, + easing: 'easeOutCubic', + }) + .promise(); + this.$lastBody.remove(); + } + this.$lastBody = $newBody; + this.animating = false; + } + + buildBody() { + const data = this.buildBodyData(); + const $body = $('
'); + data.forEach((week) => { + const $row = $(tpl`
+
+
+
`); + const $bgContainer = $row.find('.calendar__row__bg tr'); + const $numContainer = $row.find('.calendar__row__content thead tr'); + const $bannerContainer = $row.find('.calendar__row__content tbody'); + week.days.forEach((day) => { + const isInactive = day.active ? '' : 'is-inactive'; + const isCurrentDay = day.current ? 'is-current-day' : ''; + const today = day.current ? ' (TODAY)' : ''; + $bgContainer.append($('').addClass(isInactive).addClass(isCurrentDay)); + $numContainer.append($(tpl`${day.date.format('D')}${today}`).addClass(isInactive).addClass(isCurrentDay)); + }); + week.banners.forEach((banners) => { + const $tr = $(''); + banners.forEach((bannerSpan) => { + if (!bannerSpan.banner) { + $tr.append(tpl``); + return; + } + const $cell = $(tpl``); + const $banner = $(tpl` + ${bannerSpan.banner.mask ? bannerSpan.banner.event.maskTitle : bannerSpan.banner.event.title} + `); + if (bannerSpan.banner.mask) { + $banner.addClass('is-masked'); + } + if (bannerSpan.banner.beginTrunc) { + $banner.addClass('is-trunc-begin'); + } else if (bannerSpan.banner.beginSnap) { + $banner.addClass('is-snap-begin'); + } + if (bannerSpan.banner.endTrunc) { + $banner.addClass('is-trunc-end'); + } else if (bannerSpan.banner.endSnap) { + $banner.addClass('is-snap-end'); + } + $cell.append($banner); + $tr.append($cell); + }); + $bannerContainer.append($tr); + }); + $body.append($row); + }); + return $body; + } + + buildBodyData() { + const now = moment(); + const days = []; + { + // back fill + const base = this.current.clone(); + const dayOfWeek = base.day(); + if (dayOfWeek > 0) { + base.subtract(dayOfWeek + 1, 'days'); + for (let i = dayOfWeek; i > 0; --i) { + days.push({ + active: false, + date: base.add(1, 'days').clone(), + }); + } + } + } + { + // current month + const base = this.current.clone(); + while (base.month() === this.current.month()) { + days.push({ + active: true, + date: base.clone(), + }); + base.add(1, 'days'); + } + } + { + // forward fill + const base = this.current.clone().add(1, 'months').subtract(1, 'days'); + const dayOfWeek = base.day(); + if (dayOfWeek < 6) { + for (let i = dayOfWeek; i < 6; ++i) { + days.push({ + active: false, + date: base.add(1, 'days').clone(), + }); + } + } + } + + days.forEach((day) => { + day.current = day.date.isSame(now, 'day'); // eslint-disable-line no-param-reassign + }); + + const daysByWeek = _.chunk(days, 7); + + const numberOfWeeks = days.length / 7; + const bannersByWeek = _.fill(new Array(numberOfWeeks), 1).map(() => []); + const beginDate = days[0].date.clone(); + const endDate = _.last(days).date.clone(); + + // cut events by week to banners + this.events.forEach((ev) => { + if (ev.endAt.isBefore(ev.beginAt, 'day')) { + return; + } + if (ev.endAt.isBefore(beginDate, 'day') || ev.beginAt.isAfter(endDate, 'day')) { + return; + } + if (ev.beginAt.hour() >= 22) { + ev.beginAt.add(1, 'day').startOf('day'); + } + if (ev.endAt.hour() <= 2) { + ev.endAt.subtract(1, 'day').endOf('day'); + } + let [bannerBegin, bannerBeginTruncated] = [ev.beginAt.clone(), false]; + if (bannerBegin.isBefore(beginDate, 'day')) { + [bannerBegin, bannerBeginTruncated] = [beginDate.clone(), true]; + } + do { + const bannerEndMax = bannerBegin.clone().endOf('week'); + let [bannerEnd, bannerEndTruncated] = [ev.endAt.clone(), false]; + if (bannerEnd.isAfter(bannerEndMax, 'day')) { + [bannerEnd, bannerEndTruncated] = [bannerEndMax, true]; + } + const weekIndex = bannerBegin.clone().startOf('day').diff(beginDate.clone().startOf('day'), 'week'); + bannersByWeek[weekIndex].push({ + beginAt: bannerBegin.startOf('day'), + beginTrunc: bannerBeginTruncated, + endAt: bannerEnd.endOf('day'), + endTrunc: bannerEndTruncated, + event: ev, + }); + if (!bannerEndTruncated) { + break; + } + [bannerBegin, bannerBeginTruncated] = [bannerEnd.clone().add(1, 'day'), true]; + } while (!bannerBegin.isAfter(endDate, 'day')); + }); + + // layout banners + const layout = bannersByWeek + .map((banners) => _ + .sortBy(banners, [ + (banner) => banner.beginAt.valueOf(), + (banner) => (banner.beginTrunc ? 0 : 1), // truncated events first + (banner) => -banner.endAt.valueOf(), // long events first + ])) + .map((banners) => { + const dayBitmap = _ + .fill(new Array(7), 1) + .map(() => []); + banners.forEach((banner) => { + const beginDay = banner.beginAt.day(); + const endDay = banner.endAt.day(); + // find available space + const vIndexMax = _.max(_ + .range(beginDay, endDay + 1) + .map((day) => dayBitmap[day].length)); + let vIndex = 0; + for (; vIndex < vIndexMax; ++vIndex) { + if (_.every(_ + .range(beginDay, endDay + 1) + .map((day) => !dayBitmap[day][vIndex]) // eslint-disable-line no-loop-func + )) { // eslint-disable-line function-paren-newline + break; + } + } + // fill space + for (let i = beginDay; i <= endDay; ++i) { + dayBitmap[i][vIndex] = banner; + } + }); + // merge adjacent cells and arrange banners by vertical index + const vMaxLength = _.max(_.range(0, 7).map((day) => dayBitmap[day].length)); + const weekBanners = _ + .fill(new Array(vMaxLength), 1) + .map(() => []); + for (let vIndex = 0; vIndex < vMaxLength; ++vIndex) { + let last = { span: 1, banner: dayBitmap[0][vIndex] }; + weekBanners[vIndex].push(last); + for (let day = 1; day < 7; ++day) { + const banner = dayBitmap[day][vIndex]; + if (banner !== last.banner) { + last = { span: 1, banner }; + weekBanners[vIndex].push(last); + } else { + last.span++; + } + } + } + // cut banners by masked areas, scanning from left to right + weekBanners.forEach((bannerSpans) => { + for (let i = 0; i < bannerSpans.length; ++i) { + const { banner } = bannerSpans[i]; + if (!banner) { + continue; + } + if (banner.mask) { + continue; + } + if (!banner.event.maskFrom) { + continue; + } + if (banner.event.maskFrom.isSame(banner.event.endAt)) { + // do not show masks if maskFrom === endAt + continue; + } + if (banner.event.endAt.isSame(banner.event.beginAt, 'day')) { + // do not show masks if endAt - beginAt <= 1 day + continue; + } + if (banner.event.maskFrom.isAfter(banner.endAt, 'day')) { + // we are not in the time region for masking + continue; + } + if (banner.event.maskFrom.isSameOrBefore(banner.beginAt, 'day')) { + // mask begins before this banner: replace current banner with masked banner + banner.mask = true; + } else { + // mask begins during this banner: cut current banner into two pieces + const newBanner = { + ...banner, + beginAt: banner.event.maskFrom.clone(), + beginSnap: true, + beginTrunc: false, + mask: true, + }; + const newBannerSpan = { + span: newBanner.endAt.day() - newBanner.beginAt.day() + 1, + banner: newBanner, + }; + banner.endAt = banner.event.maskFrom.clone().subtract(1, 'day'); + banner.endSnap = true; + bannerSpans[i].span -= newBannerSpan.span; // eslint-disable-line no-param-reassign + bannerSpans.splice(i + 1, 0, newBannerSpan); + i++; + } + } + }); + return weekBanners; + }); + return daysByWeek + .map((daysInWeek, weekIndex) => ({ + days: daysInWeek, + banners: layout[weekIndex], + })); + } +} diff --git a/packages/ui-default/components/cmeditor/cmeditor.page.js b/packages/ui-default/components/cmeditor/cmeditor.page.js new file mode 100644 index 000000000..8d4459c1a --- /dev/null +++ b/packages/ui-default/components/cmeditor/cmeditor.page.js @@ -0,0 +1,20 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import delay from 'vj/utils/delay'; +import CmEditor from '.'; + +function runSubstitute($container) { + const selector = ['textarea[data-markdown]']; + $container.find(selector.join(', ')).get().forEach((element) => { + CmEditor.getOrConstruct($(element)); + }); +} + +const cmEditorPage = new AutoloadPage('cmEditorPage', () => { + runSubstitute($('body')); + $(document).on('vjContentNew', async (e) => { + await delay(0); + runSubstitute($(e.target)); + }); +}); + +export default cmEditorPage; diff --git a/packages/ui-default/components/cmeditor/cmeditor.styl b/packages/ui-default/components/cmeditor/cmeditor.styl new file mode 100644 index 000000000..6c00da3f4 --- /dev/null +++ b/packages/ui-default/components/cmeditor/cmeditor.styl @@ -0,0 +1,91 @@ +.simplemde + border: 1px solid $editor-border-color + margin: 0 0 1rem + position: relative + height: rem(200px) + box-sizing: border-box + overflow: hidden + +.cm-s-simplemde + border: 0 + min-height: 0 + height: auto + padding: 0 + position: absolute + left: 0 + top: 40px + width: 100% + bottom: 0 + color: inherit + +.simplemde-toolbar + position: absolute + left: 0 + top: 0 + width: 100% + border-radius: 0 + border: 0 + opacity: 0 + background: $toolbar-bg + transform: translateY(-20px) + transition: opacity .5s, transform .5s + transition-timing-function: ease-out-cubic + + &:hover + opacity: 1 + +.toolbar--visible .simplemde-toolbar + opacity: 1 + transform: translateY(0) + +.cm-s-simplemde .CodeMirror-scroll + height: 100% + min-height: 0 + font-family: $code-font-family + color: $text-1-color + +.simplemde-toolbar:before, +.simplemde-toolbar:after + display: none + +.simplemde-toolbar + padding: rem(5px) + +.simplemde-toolbar i.separator + border-right: 0 + +.simplemde-toolbar a + color: $toolbar-fore !important + border: 0 + background: none + border-radius: 0 + font-size: rem($font-size-icon) + outline: 0 + + &:hover + background: $toolbar-bg-hover + color: $toolbar-fore-hover !important + border: 0 + + &.active + background: $toolbar-bg-selected + color: $toolbar-fore-selected !important + border: 0 + +.simplemde-toolbar.disabled-for-preview a:not(.no-disable) + background: none + opacity: $toolbar-disabled-opacity + +.cm-s-simplemde .CodeMirror-code .cm-comment + background: none + +.simplemde-statusbar + display: none + +.simplemde-preview + background: #FFF + +.no-heading .cm-s-simplemde .CodeMirror-code + .cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4 + font-size: 1em + line-height: 1.5 diff --git a/packages/ui-default/components/cmeditor/index.js b/packages/ui-default/components/cmeditor/index.js new file mode 100644 index 000000000..bf6cea053 --- /dev/null +++ b/packages/ui-default/components/cmeditor/index.js @@ -0,0 +1,66 @@ +import _ from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; + +export default class CmEditor extends DOMAttachedObject { + static DOMAttachKey = 'vjCmEditorInstance'; + + constructor($dom, options = {}) { + super($dom); + this.editor = null; + this.options = options; + this.init(); + } + + async init() { + const { default: VjCmEditor } = await import('./vjcmeditor'); + + const hasFocus = this.$dom.is(':focus'); + const srcHeight = this.$dom.outerHeight(); + + this.editor = new VjCmEditor({ + ...this.options, + element: this.$dom.get(0), + }); + this.moveToEnd(); + + const $editor = $(this.editor.wrapper); + $editor.css('height', srcHeight); + $editor.addClass('toolbar--visible'); + + if (hasFocus) { + this.focus(); + } + } + + isValid() { + return (this.editor !== null); + } + + ensureValid() { + if (!this.isValid()) { + throw new Error('VjCmEditor is not loaded'); + } + } + + value(...argv) { + this.ensureValid(); + const ret = this.editor.value(...argv); + if (argv.length > 0) { + this.moveToEnd(); + } + return ret; + } + + focus() { + this.ensureValid(); + this.editor.codemirror.focus(); + } + + moveToEnd() { + this.ensureValid(); + const cm = this.editor.codemirror; + cm.setCursor(cm.lineCount(), 0); + } +} + +_.assign(CmEditor, DOMAttachedObject); diff --git a/packages/ui-default/components/cmeditor/textareaHandler.js b/packages/ui-default/components/cmeditor/textareaHandler.js new file mode 100644 index 000000000..c959c4d83 --- /dev/null +++ b/packages/ui-default/components/cmeditor/textareaHandler.js @@ -0,0 +1,32 @@ +import _ from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import CmEditor from '.'; + +export default class TextareaHandler extends DOMAttachedObject { + static DOMAttachKey = 'vjTextareaHandlerInstance'; + + getCmEditor() { + return CmEditor.get(this.$dom); + } + + isCmEditor() { + const editor = this.getCmEditor(); + return (editor !== undefined && editor.isValid()); + } + + val(...argv) { + if (this.isCmEditor()) { + return this.getCmEditor().value(...argv); + } + return this.$dom.val(...argv); + } + + focus() { + if (this.isCmEditor()) { + this.getCmEditor().focus(); + } + this.$dom.focus(); + } +} + +_.assign(TextareaHandler, DOMAttachedObject); diff --git a/packages/ui-default/components/cmeditor/vjcmeditor.js b/packages/ui-default/components/cmeditor/vjcmeditor.js new file mode 100644 index 000000000..008b02650 --- /dev/null +++ b/packages/ui-default/components/cmeditor/vjcmeditor.js @@ -0,0 +1,120 @@ +import _ from 'lodash'; +import SimpleMDE from 'vj-simplemde'; + +import request from 'vj/utils/request'; +import i18n from 'vj/utils/i18n'; + +export default class VjCmEditor extends SimpleMDE { + constructor(options = {}) { + super({ + autoDownloadFontAwesome: false, + spellChecker: false, + forceSync: true, + toolbar: [ + { + name: 'bold', + action: SimpleMDE.toggleBold, + className: 'icon icon-bold', + title: i18n('Bold'), + }, + { + name: 'italic', + action: SimpleMDE.toggleItalic, + className: 'icon icon-italic', + title: i18n('Italic'), + }, + '|', + { + name: 'quote', + action: SimpleMDE.toggleBlockquote, + className: 'icon icon-quote', + title: i18n('Quote'), + }, + { + name: 'unordered-list', + action: SimpleMDE.toggleUnorderedList, + className: 'icon icon-unordered_list', + title: i18n('Unordered List'), + }, + { + name: 'ordered-list', + action: SimpleMDE.toggleOrderedList, + className: 'icon icon-ordered_list', + title: i18n('Ordered List'), + }, + '|', + { + name: 'code', + action: () => this.insertCodeBlock(), + className: 'icon icon-code', + title: i18n('Insert Code'), + default: true, + }, + { + name: 'link', + action: SimpleMDE.drawLink, + className: 'icon icon-link', + title: i18n('Create Link'), + default: true, + }, + { + name: 'image', + action: SimpleMDE.drawImage, + className: 'icon icon-insert--image', + title: i18n('Insert Image'), + default: true, + }, + '|', + { + name: 'preview', + action: SimpleMDE.togglePreview, + preAction: SimpleMDE.preRenderPreview, + className: 'icon icon-preview no-disable', + title: i18n('Toggle Preview'), + default: true, + }, + ], + commonmark: { + safe: true, + }, + ...options, + }); + } + + async markdown(text) { + // FIXME preview wont update after first toggle + const data = await request.ajax({ + url: '/markdown', + method: 'post', + data: $.param({ text }, true), + dataType: 'text', + }); + _.defer(this.preparePreview.bind(this)); + return data; + } + + preparePreview() { + const $preview = $(this.wrapper).find('.simplemde-preview'); + $preview.addClass('typo'); + $preview.attr('data-emoji-enabled', 'true'); + $preview.trigger('vjContentNew'); + } + + insertCodeBlock() { + const text = this.codemirror.getSelection(); + const startPoint = this.codemirror.getCursor('start'); + const endPoint = this.codemirror.getCursor('end'); + const leadingLines = (startPoint.line === 0 && startPoint.ch === 0) ? 0 : 2; + // eslint-disable-next-line prefer-template + const startText = _.repeat('\n', leadingLines) + '```cpp\n'; + const endText = '\n```\n'; + this.codemirror.replaceSelection(`${startText}${text}${endText}`); + startPoint.line += leadingLines + 1; + startPoint.ch = 0; + if (startPoint !== endPoint) { + endPoint.line += leadingLines + 1; + } + this.codemirror.setSelection(startPoint, endPoint); + this.codemirror.focus(); + } +} diff --git a/packages/ui-default/components/contest/contest.page.styl b/packages/ui-default/components/contest/contest.page.styl new file mode 100644 index 000000000..7fd9ba71b --- /dev/null +++ b/packages/ui-default/components/contest/contest.page.styl @@ -0,0 +1,34 @@ +.contest-type-tag +.contest-tag + font-size: rem($font-size-small) + display: inline-block + line-height: rem(22px) + padding: rem(0 10px) + vertical-align: middle + border-radius: rem(12px) + + &:hover + text-decoration: none + color: #FFF !important + +.contest-type-tag + &, &:visited + &, .supplementary & + color: #FFF + + .icon + margin-right: rem(5px) + +for key, value in $contest-color + .contest-type--{key} .contest-type-tag + background: value + + &:hover + background: lighten(value, 20%) + +.contest-tag + border: 1px solid $supplementary-border-color + + &:hover + background: $primary-color + border-color: $primary-color diff --git a/packages/ui-default/components/contest/contest_sidebar.page.styl b/packages/ui-default/components/contest/contest_sidebar.page.styl new file mode 100644 index 000000000..8e684f686 --- /dev/null +++ b/packages/ui-default/components/contest/contest_sidebar.page.styl @@ -0,0 +1,21 @@ +.contest-sidebar__bg + display: block + background: #67AABB url(problem-contest-bg.png) right top no-repeat + color: #FFF !important + font-size: rem($font-size) + padding: rem(20px 0) + + +retina() + background-image: url(problem-contest-bg@2x.png) + background-size: 131px 150px + + h1 + font-size: rem($font-size-title) + color: #FFF + text-shadow: 0 1px 3px rgba(#000, 0.5) + + &:hover + text-decoration: none + + .contest-sidebar__status + margin-top: rem(20px) diff --git a/packages/ui-default/components/contest/problem-contest-bg.png b/packages/ui-default/components/contest/problem-contest-bg.png new file mode 100644 index 000000000..63ea08cad Binary files /dev/null and b/packages/ui-default/components/contest/problem-contest-bg.png differ diff --git a/packages/ui-default/components/contest/problem-contest-bg@2x.png b/packages/ui-default/components/contest/problem-contest-bg@2x.png new file mode 100644 index 000000000..16272e1af Binary files /dev/null and b/packages/ui-default/components/contest/problem-contest-bg@2x.png differ diff --git a/packages/ui-default/components/datepicker/datepicker.page.js b/packages/ui-default/components/datepicker/datepicker.page.js new file mode 100644 index 000000000..ae30c2a98 --- /dev/null +++ b/packages/ui-default/components/datepicker/datepicker.page.js @@ -0,0 +1,21 @@ +import { AutoloadPage } from 'vj/misc/Page'; + +const datepickerPage = new AutoloadPage('datepickerPage', async () => { + if ($('[data-pick-date]').length > 0) { + await import('pickadate/lib/picker.date'); + $('[data-pick-date]').pickadate({ + format: 'yyyy-m-d', + clear: false, + }); + } + if ($('[data-pick-time]').length > 0) { + await import('pickadate/lib/picker.time'); + $('[data-pick-time]').pickatime({ + format: 'H:i', + interval: 15, + clear: false, + }); + } +}); + +export default datepickerPage; diff --git a/packages/ui-default/components/datepicker/datepicker.styl b/packages/ui-default/components/datepicker/datepicker.styl new file mode 100644 index 000000000..16e60db05 --- /dev/null +++ b/packages/ui-default/components/datepicker/datepicker.styl @@ -0,0 +1,54 @@ +.picker__holder + margin-top: -1rem + border-radius: 0 + transform: scale(0.9) translateZ(0) + transform-origin: 0 0 + outline: 0 + font-size: rem($font-size-secondary) + transition: transform .1s ease-in-out, + opacity .1s ease-in-out, + max-height 0s .1s, + border-width 0s .1s + +.picker--opened .picker__holder + transform: scale(1) translateZ(0) + transition: transform .1s ease-in-out, + opacity .1s ease-in-out, + max-height 0s, + border-width 0s + box-shadow: $data-picker-shadow + +.picker__list + padding: 0 + +.picker__list-item + border: 0 + +.picker__list-item:hover + background: $datapicker-list-item-bg-color-hover + +.picker__list-item--selected, .picker__list-item--selected:hover, .picker--focused .picker__list-item--selected + background: $primary-color + +.picker__day--infocus:hover, .picker__day--outfocus:hover + background: $datapicker-list-item-bg-color-hover + +.picker__day--highlighted + border-color: transparent + +.picker__day--highlighted:hover, .picker--focused .picker__day--highlighted + background: transparent + +.picker__day--selected, .picker__day--selected:hover, .picker--focused .picker__day--selected + background: $primary-color + +.picker__button--today:hover, .picker__button--clear:hover, .picker__button--close:hover + background: $datapicker-list-item-bg-color-hover + border-color: $datapicker-list-item-bg-color-hover + +.picker__button--today:focus, .picker__button--clear:focus, .picker__button--close:focus + background: $datapicker-list-item-bg-color-hover + border-color: $datapicker-list-item-bg-color-hover + +.picker__nav--prev:hover, .picker__nav--next:hover + background: $datapicker-list-item-bg-color-hover diff --git a/packages/ui-default/components/dialog/DomDialog.js b/packages/ui-default/components/dialog/DomDialog.js new file mode 100644 index 000000000..a401f143e --- /dev/null +++ b/packages/ui-default/components/dialog/DomDialog.js @@ -0,0 +1,145 @@ +import _ from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; + +import zIndexManager from 'vj/utils/zIndexManager'; + +export default class DomDialog extends DOMAttachedObject { + static DOMAttachKey = 'vjDomDialogInstance'; + + constructor($dom, options = {}) { + super($dom); + this.isShown = false; + this.isAnimating = false; + this.options = { + cancelByClickingBack: false, + cancelByEsc: false, + onDispatch: () => {}, + ...options, + }; + this._defer = null; + } + + async _show() { + this.$dom.css('z-index', zIndexManager.getNext()); + this.$dom.trigger('vjDomDialogShow'); + this.isAnimating = true; + + if (this.options.cancelByClickingBack) { + this.$dom.on(`click.${this.eventNS}`, this.handleClick.bind(this)); + } + if (this.options.cancelByEsc) { + $(document).on(`keyup.${this.eventNS}`, this.handleKeyUp.bind(this)); + } + + const $wrap = this.$dom; + $wrap.css({ + display: 'flex', + opacity: 0, + }); + $wrap.width(); + $wrap.transition({ + opacity: 1, + }, { + duration: 100, + easing: 'easeOutCubic', + }); + + const $dgContent = this.$dom.find('.dialog__content'); + $dgContent.css({ + scale: 0.8, + }); + await $dgContent + .transition({ + scale: 1, + }, { + duration: 200, + easing: 'easeOutCubic', + }) + .promise(); + + this.$dom.find('[data-autofocus]').focus(); + + this.isShown = true; + this.isAnimating = false; + this.$dom.trigger('vjDomDialogShown'); + } + + async _hide() { + this.$dom.trigger('vjDomDialogHide'); + this.isAnimating = true; + + $(document).off(`keyup.${this.eventNS}`); + this.$dom.off(`click.${this.eventNS}`); + + this.$dom.css({ + opacity: 1, + }); + this.$dom.transition({ + opacity: 0, + }, { + duration: 200, + }); + + const $dgContent = this.$dom.find('.dialog__content'); + $dgContent.css({ + scale: 1, + }); + await $dgContent + .transition({ + scale: 0.8, + }, { + duration: 200, + easing: 'easeOutCubic', + }) + .promise(); + + this.$dom.css('display', 'none'); + + this.isShown = false; + this.isAnimating = false; + this.$dom.trigger('vjDomDialogHidden'); + } + + show() { + if (this.isShown || this.isAnimating) { + return Promise.reject(); + } + this._defer = new $.Deferred(); + this._show(); + return this._defer.promise(); + } + + hide() { + if (!this.isShown || this.isAnimating) { + return false; + } + if (this._defer.state() === 'pending') { + this._defer.resolve('cancel'); + } + this._defer = null; + this._hide(); + return true; + } + + dispatchAction(data) { + if (this.options.onDispatch(data) === false) { + return; + } + this._defer.resolve(data); + this.hide(); + } + + handleClick(e) { + if (e.target === this.$dom.get(0)) { + this.dispatchAction('cancel'); + } + } + + handleKeyUp(e) { + if (e.keyCode === 27) { + this.dispatchAction('cancel'); + } + } +} + +_.assign(DomDialog, DOMAttachedObject); diff --git a/packages/ui-default/components/dialog/dialog.page.styl b/packages/ui-default/components/dialog/dialog.page.styl new file mode 100644 index 000000000..b4a5da94e --- /dev/null +++ b/packages/ui-default/components/dialog/dialog.page.styl @@ -0,0 +1,38 @@ +.dialog + position: fixed + left: 0 + top: 0 + width: 100% + height: 100% + opacity: 0 + z-index: 1000 // managed + // display flex will be added after animation completes + flex-direction: column + align-items: center + justify-content: center + display: none + background: $dialog-layer-bg-color + +.dialog__content + position: relative + +.dialog.withBg .dialog__content + background: $dialog-bg-color + border: 1px solid $dialog-border-color + box-shadow: $dialog-shadow + min-width: rem(500px) + max-width: rem(900px) + padding: rem(40px) rem(30px) + +.dialog__body + margin-bottom: rem(20px) + min-height: rem(50px) + + h1 + font-size: 1.6rem + color: $dialog-title-color + margin-bottom: 1rem + +.dialog__action button + margin-left: rem(10px) + margin-bottom: 0 diff --git a/packages/ui-default/components/dialog/index.js b/packages/ui-default/components/dialog/index.js new file mode 100644 index 000000000..ea9d84581 --- /dev/null +++ b/packages/ui-default/components/dialog/index.js @@ -0,0 +1,99 @@ +import tpl from 'vj/utils/tpl'; +import i18n from 'vj/utils/i18n'; +import DomDialog from './DomDialog'; + +export default class Dialog { + constructor(options = {}) { + this.options = { + classes: '', + $body: null, + $action: null, + ...options, + }; + this.$dom = $(tpl` + + `); + this.$dom.on('click', '[data-action]', this.handleActionButton.bind(this)); + this.$dom.on('vjDomDialogShow', this.beforeShow.bind(this)); + this.$dom.on('vjDomDialogHidden', this.afterHide.bind(this)); + this.$dom.find('.dialog__body').append(this.options.$body); + this.$dom.find('.dialog__action').append(this.options.$action); + this.domDialogInstance = new DomDialog(this.$dom, this.options); + } + + beforeShow() { + this.$dom.appendTo('body'); + } + + afterHide() { + this.$dom.detach(); + } + + open() { + return this.domDialogInstance.show(); + } + + close() { + return this.domDialogInstance.hide(); + } + + handleActionButton(ev) { + this.domDialogInstance.dispatchAction($(ev.currentTarget).attr('data-action')); + } +} + +const buttonOk = tpl``; +const buttonCancel = tpl``; +const buttonYes = tpl``; +const buttonNo = tpl``; + +export class InfoDialog extends Dialog { + constructor(options = {}) { + super({ + $action: buttonOk, + cancelByClickingBack: true, + cancelByEsc: true, + ...options, + }); + } +} + +export class ActionDialog extends Dialog { + constructor(options = {}) { + super({ + $action: [buttonCancel, buttonOk].join('\n'), + cancelByClickingBack: true, + cancelByEsc: true, + ...options, + }); + } +} + +export class ConfirmDialog extends Dialog { + constructor(options = {}) { + let buttons = []; + if (options.canCancel) { + buttons = [buttonCancel, buttonNo, buttonYes]; + } else { + buttons = [buttonNo, buttonYes]; + } + super({ + $action: buttons.join('\n'), + cancelByClickingBack: options.canCancel, + cancelByEsc: options.canCancel, + ...options, + }); + } +} + +window.Hydro.components.Dialog = Dialog; +window.Hydro.components.InfoDialog = InfoDialog; +window.Hydro.components.ActionDialog = ActionDialog; +window.Hydro.components.ConfirmDialog = ConfirmDialog; diff --git a/packages/ui-default/components/discussion/CommentBox.js b/packages/ui-default/components/discussion/CommentBox.js new file mode 100644 index 000000000..23146ecb8 --- /dev/null +++ b/packages/ui-default/components/discussion/CommentBox.js @@ -0,0 +1,105 @@ +import _ from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import request from 'vj/utils/request'; +import TextareaHandler from 'vj/components/cmeditor/textareaHandler'; +import Notification from 'vj/components/notification'; + +let initialized = false; +const $template = $('.dczcomments__box').eq(0).clone(); + +function getClosestInstance($dom) { + return $dom.closest('.dczcomments__box').data('instance'); +} + +function onBoxCancel(ev) { + const commentBox = getClosestInstance($(ev.currentTarget)); + if (commentBox) commentBox.onCancel(ev); +} + +function onBoxSubmit(ev) { + const commentBox = getClosestInstance($(ev.currentTarget)); + if (commentBox) commentBox.onSubmit(ev); + ev.preventDefault(); +} + +function init() { + if (initialized) return; + $(document).on('submit', '[name="dczcomments__box__form"]', onBoxSubmit); + $(document).on('vjCommentBoxCancel', '[name="dczcomments__box__form"]', onBoxCancel); + $(document).on('click', '[name="dczcomments__box__cancel"]', onBoxCancel); + initialized = true; +} + +export default class CommentBox extends DOMAttachedObject { + static DOMAttachKey = 'vjCommentBoxInstance'; + + constructor($dom, options = {}) { + super($dom); + init(); + this.$box = $template.clone(); + this.$box.data('instance', this); + this.options = { + initialText: null, + mode: null, + form: null, + onCancel: () => null, + ...options, + }; + if (this.options.initialText) this.setText(this.options.initialText); + if (this.options.mode) { + const submitButton = this.$box.find('.dczcomments__box__submit'); + submitButton.val(submitButton.attr(`data-value-${this.options.mode}`)); + } + } + + getTextareaHandler() { + const $textarea = this.$box.find('textarea'); + return TextareaHandler.getOrConstruct($textarea); + } + + focus() { + this.getTextareaHandler().focus(); + return this; + } + + setText(text) { + this.getTextareaHandler().val(text); + return this; + } + + getText() { + return this.getTextareaHandler().val(); + } + + insertText(text) { + const handler = this.getTextareaHandler(); + handler.val(handler.val() + text); + return this; + } + + appendTo($dom) { + this.$box.appendTo($dom); + this.$box.trigger('vjContentNew'); + return this; + } + + async onSubmit() { + try { + await request.post('', { + ...this.options.form, + content: this.getText(), + }); + window.location.reload(); + } catch (error) { + Notification.error(error.message); + } + } + + async onCancel(ev) { + await this.options.onCancel(ev); + this.$box.remove(); + this.detach(); + } +} + +_.assign(CommentBox, DOMAttachedObject); diff --git a/packages/ui-default/components/discussion/comments.page.js b/packages/ui-default/components/discussion/comments.page.js new file mode 100644 index 000000000..a18978b53 --- /dev/null +++ b/packages/ui-default/components/discussion/comments.page.js @@ -0,0 +1,206 @@ +import 'jquery.easing'; + +import { AutoloadPage } from 'vj/misc/Page'; +import CommentBox from 'vj/components/discussion/CommentBox'; +import { ConfirmDialog } from 'vj/components/dialog'; + +import delay from 'vj/utils/delay'; +import { slideDown, slideUp } from 'vj/utils/slide'; +import request from 'vj/utils/request'; +import i18n from 'vj/utils/i18n'; +import tpl from 'vj/utils/tpl'; + +const $replyTemplate = $('.commentbox-container').eq(0).clone(); + +function createReplyContainer($parent) { + const $container = $replyTemplate + .clone() + .hide() + .prependTo($parent.find('.commentbox-reply-target').eq(0)) + .trigger('vjContentNew'); + return $container.find('.commentbox-placeholder'); +} + +async function showReplyContainer($parent) { + const $container = $parent.find('.commentbox-container'); + // TODO: fix ugly hack. cannot get $container rect because it is invisible + const rect = $container.parent()[0].getBoundingClientRect(); + const rectBody = document.body.getBoundingClientRect(); + if (rect.top < 100 || rect.top + 100 > window.innerHeight) { + const targetScrollTop = rect.top - rectBody.top - window.innerHeight * 0.382; + $('html, body').stop().animate({ scrollTop: targetScrollTop }, 400, 'easeOutCubic'); + await delay(300); + // delay duration is set smaller than animation duration intentionally + } + $container.css('opacity', 0); + await slideDown($container, 300); + await $container.transition({ opacity: 1 }, { duration: 200 }).promise(); + $container.removeAttr('style'); +} + +async function destroyReplyContainer($parent) { + const $container = $parent.find('.commentbox-container'); + $container.css('opacity', 1); + await $container.transition({ opacity: 0 }, { duration: 200 }).promise(); + await slideUp($container, 300); + $container.remove(); +} + +function onClickDummyBox(ev) { + const $evTarget = $(ev.currentTarget); + + if (CommentBox.get($evTarget)) { + CommentBox + .get($evTarget) + .focus(); + return; + } + + const $mediaBody = $evTarget.closest('.media__body'); + + const opt = { + form: JSON.parse($evTarget.attr('data-form')), + mode: 'comment', + onCancel: () => { + $mediaBody.removeClass('is-editing'); + }, + }; + + $mediaBody.addClass('is-editing'); + + CommentBox + .getOrConstruct($evTarget, opt) + .appendTo($mediaBody.find('.commentbox-placeholder').eq(0)) + .focus(); +} + +async function onCommentClickReplyComment(ev, options = {}) { + const $evTarget = $(ev.currentTarget); + + if (CommentBox.get($evTarget)) { + // If comment box is already expanded, + // we should insert "initialText" + CommentBox + .get($evTarget) + .insertText(options.initialText || '') + .focus(); + return; + } + + const $mediaBody = $evTarget.closest('.media__body'); + + const opt = { + initialText: '', + mode: 'reply', + ...options, + onCancel: async () => { + await destroyReplyContainer($mediaBody); + }, + }; + + const cbox = CommentBox + .getOrConstruct($evTarget, { + form: JSON.parse($evTarget.attr('data-form')), + ...opt, + }) + .appendTo(createReplyContainer($mediaBody)); + await showReplyContainer($mediaBody); + cbox.focus(); +} + +async function onCommentClickReplyReply(ev) { + const $evTarget = $(ev.currentTarget); + const $mediaBody = $evTarget.closest('.media__body'); + const username = $mediaBody + .find('.user-profile-name').eq(0) + .text(); + + $evTarget + .closest('.dczcomments__item') + .find('[name="dczcomments__op-reply-comment"]').eq(0) + .trigger('click', { initialText: `@${username}: ` }); +} + +async function onCommentClickEdit(mode, ev) { + const $evTarget = $(ev.currentTarget); + + if (CommentBox.get($evTarget)) { + CommentBox + .get($evTarget) + .focus(); + return; + } + + const $mediaBody = $evTarget.closest('.media__body'); + + const raw = await request.get( + $mediaBody.find('.typo').eq(0).attr('data-raw-url'), + {}, + { dataType: 'text' }, + ); + + const opt = { + initialText: raw, + form: JSON.parse($evTarget.attr('data-form')), + mode, + onCancel: () => { + $mediaBody.removeClass('is-editing'); + }, + }; + + $mediaBody.addClass('is-editing'); + + CommentBox + .getOrConstruct($evTarget, opt) + .appendTo($mediaBody.find('.commentbox-edit-target').eq(0)) + .focus(); +} + +function onCommentClickEditComment(ev) { + return onCommentClickEdit('comment-update', ev); +} + +function onCommentClickEditReply(ev) { + return onCommentClickEdit('reply-update', ev); +} + +async function onCommentClickDelete(type, ev) { + const message = (type === 'comment') + ? 'Confirm deleting this comment? Its replies will be deleted as well.' + : 'Confirm deleting this reply?'; + const action = await new ConfirmDialog({ + $body: tpl` +
+

${i18n(message)}

+
`, + }).open(); + if (action !== 'yes') { + return; + } + + const $evTarget = $(ev.currentTarget); + const form = JSON.parse($evTarget.attr('data-form')); + + await request.post('', form); + window.location.reload(); +} + +function onCommentClickDeleteComment(ev) { + onCommentClickDelete('comment', ev); +} + +function onCommentClickDeleteReply(ev) { + onCommentClickDelete('reply', ev); +} + +const commentsPage = new AutoloadPage('commentsPage', () => { + $(document).on('click', '[name="dczcomments__dummy-box"]', onClickDummyBox); + $(document).on('click', '[name="dczcomments__op-reply-comment"]', onCommentClickReplyComment); + $(document).on('click', '[name="dczcomments__op-reply-reply"]', onCommentClickReplyReply); + $(document).on('click', '[name="dczcomments__op-edit-comment"]', onCommentClickEditComment); + $(document).on('click', '[name="dczcomments__op-edit-reply"]', onCommentClickEditReply); + $(document).on('click', '[name="dczcomments__op-delete-comment"]', onCommentClickDeleteComment); + $(document).on('click', '[name="dczcomments__op-delete-reply"]', onCommentClickDeleteReply); +}); + +export default commentsPage; diff --git a/packages/ui-default/components/discussion/comments.page.styl b/packages/ui-default/components/discussion/comments.page.styl new file mode 100644 index 000000000..4cb8106bc --- /dev/null +++ b/packages/ui-default/components/discussion/comments.page.styl @@ -0,0 +1,125 @@ +$upvote-color = green +$downvote-color = red + +.dczcomments__item .is-editing + [name="dczcomments__dummy-box"] + display: none + > .typo + display: none + +.dczcomments__box .textbox + height: rem(180px) + resize: vertical + +.textbox[name="dczcomments__dummy-box"] + height: rem(60px) + resize: none + font-size: rem($font-size-title) + color: $comment-placeholder-color + padding: rem(10px) + +.dczcomments__item, +.dczcomments__reply + border-top: 1px solid $table-border-color + + >.media + padding: rem(20px 0) + + &:first-child + border-top: 0 + +.dczcomments__reply + &:last-child + >.media + padding-bottom: 0 + +.dczcomments__supplementary + float: left + margin-bottom: rem(5px) + +.dczcomments__operations + font-size: rem($font-size) + float: right + vertical-align: top + color: $comment-op-color + + a, a:visited + color: $comment-op-link-color + margin-left: rem(3px) + a:hover + color: $primary-color + text-decoration: none + +.view--solution .dczcomments__replies + margin-left: rem(50px) + +.dczcomments__item + .vote-number + width: rem(82px) + text-align: center + font-size: rem(25px) + font-weight: 300 + margin-bottom: rem(10px) + + .vote-op + position: relative + border: 1px solid transparent + border-radius: rem(14px) + transition: border-color .2s linear, opacity .2s linear + opacity: 0.5 + + &>form + width: rem(40px) + float: left + position: relative + + &:hover .vote-op + opacity: 1 + border-color: $supplementary-border-color + + .vote-button + appearance: none + border: 0 + background: none + width: 100% + outline: 0 + font-size: rem($font-size-icon) + line-height: rem(26px) + color: #AAA + transition: color .2s linear, background-color .2s linear, box-shadow .2s linear + + &.upvote + border-radius: rem(13px 0 0 13px) + padding-left: rem(7px) + + &:hover + background: $upvote-color + color: #FFF + box-shadow: 0 2px 10px rgba($upvote-color, 0.4) + + &.active + color: $upvote-color + + + &.downvote + border-radius: rem(0 13px 13px 0) + padding-right: rem(7px) + + &:hover + background: $downvote-color + color: #FFF + box-shadow: 0 2px 10px rgba($downvote-color, 0.4) + + &.active + color: $downvote-color + + &.active + background: transparent !important + box-shadow: none !important + cursor: default + + ++mobile() + .dczcomments__item + .media__left + display: none diff --git a/packages/ui-default/components/discussion/discussion.page.styl b/packages/ui-default/components/discussion/discussion.page.styl new file mode 100644 index 000000000..387ea3a4d --- /dev/null +++ b/packages/ui-default/components/discussion/discussion.page.styl @@ -0,0 +1,17 @@ +.discussion-node-tag + font-size: rem($font-size-small) + display: inline-block + line-height: rem(22px) + padding: rem(0 10px) + vertical-align: middle + border-radius: rem(12px) + border: 1px solid $supplementary-border-color + + &:hover + background: $primary-color + color: #FFF !important + text-decoration: none + border-color: $primary-color + + .icon + margin-right: rem(5px) diff --git a/packages/ui-default/components/drop/drop.page.styl b/packages/ui-default/components/drop/drop.page.styl new file mode 100644 index 000000000..a5b964884 --- /dev/null +++ b/packages/ui-default/components/drop/drop.page.styl @@ -0,0 +1,40 @@ +.drop-element, +.drop-element:after, +.drop-element:before, +.drop-element *, +.drop-element *:after, +.drop-element *:before + box-sizing: border-box + +.drop-element + position: absolute + display: none + opacity: 0 + transition: opacity .1s ease-in-out + z-index: 1000 // managed + + &.drop-open + display: block + + &.drop-open-transitionend + display: block + + &.drop-after-open + opacity: 1 + +.drop-target-attached-top.drop-target-attached-left .drop-content + transform-origin: -10% 110% +.drop-target-attached-top.drop-target-attached-center .drop-content + transform-origin: 50% 110% +.drop-target-attached-top.drop-target-attached-right .drop-content + transform-origin: 110% 110% +.drop-target-attached-bottom.drop-target-attached-left .drop-content + transform-origin: -10% -10% +.drop-target-attached-bottom.drop-target-attached-center .drop-content + transform-origin: 50% -10% +.drop-target-attached-bottom.drop-target-attached-right .drop-content + transform-origin: 110% -10% +.drop-target-attached-center.drop-target-attached-right .drop-content + transform-origin: -10% 50% +.drop-target-attached-center.drop-target-attached-left .drop-content + transform-origin: 110% 50% diff --git a/packages/ui-default/components/dropdown/Dropdown.js b/packages/ui-default/components/dropdown/Dropdown.js new file mode 100644 index 000000000..9b48a8b1e --- /dev/null +++ b/packages/ui-default/components/dropdown/Dropdown.js @@ -0,0 +1,55 @@ +import Drop from 'tether-drop'; +import _ from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import responsiveCutoff from 'vj/breakpoints.json'; + +import zIndexManager from 'vj/utils/zIndexManager'; +import { isBelow } from 'vj/utils/mediaQuery'; + +export default class Dropdown extends DOMAttachedObject { + static DOMAttachKey = 'vjDropdownInstance'; + + static DOMAttachSelector = '[data-dropdown-target]'; + + constructor($dom, options = {}) { + if ($dom.attr('data-dropdown-trigger-desktop-only') !== undefined) { + if (isBelow(responsiveCutoff.mobile)) { + super(null); + return; + } + } + super($dom); + this.options = { + target: null, + position: $dom.attr('data-dropdown-pos') || 'bottom left', + ...options, + }; + this.dropInstance = new Drop({ + target: $dom[0], + classes: 'dropdown', + content: this.options.target || $.find($dom.attr('data-dropdown-target'))[0], + position: this.options.position, + openOn: 'hover', + constrainToWindow: true, + constrainToScrollParent: false, + }); + this.dropInstance.on('open', this.onDropOpen.bind(this)); + this.dropInstance.on('close', this.onDropClose.bind(this)); + } + + detach() { + super.detach(); + this.dropInstance.destroy(); + } + + onDropOpen() { + $(this.dropInstance.drop).css('z-index', zIndexManager.getNext()); + this.$dom.trigger('vjDropdownShow'); + } + + onDropClose() { + this.$dom.trigger('vjDropdownHide'); + } +} + +_.assign(Dropdown, DOMAttachedObject); diff --git a/packages/ui-default/components/dropdown/dropdown.page.js b/packages/ui-default/components/dropdown/dropdown.page.js new file mode 100644 index 000000000..c70eab6b1 --- /dev/null +++ b/packages/ui-default/components/dropdown/dropdown.page.js @@ -0,0 +1,8 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import Dropdown from './Dropdown'; + +const dropdownPage = new AutoloadPage('dropdownPage', () => { + Dropdown.registerLifeCycleHooks(); +}); + +export default dropdownPage; diff --git a/packages/ui-default/components/dropdown/dropdown.page.styl b/packages/ui-default/components/dropdown/dropdown.page.styl new file mode 100644 index 000000000..6cac894cc --- /dev/null +++ b/packages/ui-default/components/dropdown/dropdown.page.styl @@ -0,0 +1,24 @@ +.dropdown-target + display: none + +.drop .dropdown-target + display: block + +.dropdown + .menu + min-width: rem(150px) + box-shadow: $menu-drop-shadow + background: $menu-drop-bg-color + font-size: rem($font-size-small) + + .menu__link + padding: rem(8px 10px) + +.dropdown + .drop-content + transition: transform .1s ease-in-out + transform: scale(0.9) translateZ(0) + + &.drop-after-open + .drop-content + transform: scale(1) translateZ(0) diff --git a/packages/ui-default/components/emoji/emoji.page.styl b/packages/ui-default/components/emoji/emoji.page.styl new file mode 100644 index 000000000..7991fd566 --- /dev/null +++ b/packages/ui-default/components/emoji/emoji.page.styl @@ -0,0 +1,6 @@ +.emoji + width: 1em + height: 1em + display: inline-block + background-size: contain + vertical-align: baseline diff --git a/packages/ui-default/components/emoji/emojify.page.js b/packages/ui-default/components/emoji/emojify.page.js new file mode 100644 index 000000000..d23af474f --- /dev/null +++ b/packages/ui-default/components/emoji/emojify.page.js @@ -0,0 +1,21 @@ +import emojify from 'emojify.js'; + +import { AutoloadPage } from 'vj/misc/Page'; + +function runEmojify($container) { + if ($container.is('[data-emoji-enabled]')) { + emojify.run($container[0]); + return; + } + $container.find('[data-emoji-enabled]').get().forEach((element) => emojify.run(element)); +} + +const emojifyPage = new AutoloadPage('emojifyPage', () => { + emojify.setConfig({ + img_dir: `${UiContext.cdn_prefix}img/emoji`, + }); + runEmojify($('body')); + $(document).on('vjContentNew', (e) => runEmojify($(e.target))); +}); + +export default emojifyPage; diff --git a/packages/ui-default/components/footer/footer.page.js b/packages/ui-default/components/footer/footer.page.js new file mode 100644 index 000000000..6dfd28311 --- /dev/null +++ b/packages/ui-default/components/footer/footer.page.js @@ -0,0 +1,31 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import { isBelow } from 'vj/utils/mediaQuery'; +import { slideUp, slideDown } from 'vj/utils/slide'; +import responsiveCutoff from 'vj/breakpoints.json'; + +const footerPage = new AutoloadPage('footerPage', () => { + if ($('.footer').length === 0) { + return; + } + $('.footer__category.expandable > h1').click(async (ev) => { + if (!isBelow(responsiveCutoff.mobile)) { + return; + } + const $category = $(ev.currentTarget).closest('.footer__category'); + const $list = $category.find('.footer__category__expander'); + if ($category.hasClass('animating')) { + return; + } + $category.addClass('animating'); + if ($category.hasClass('expanded')) { + $category.removeClass('expanded'); + await slideUp($list, 300, { opacity: 1 }, { opacity: 0 }); + } else { + $category.addClass('expanded'); + await slideDown($list, 300, { opacity: 0 }, { opacity: 1 }); + } + $category.removeClass('animating'); + }); +}); + +export default footerPage; diff --git a/packages/ui-default/components/footer/footer.page.styl b/packages/ui-default/components/footer/footer.page.styl new file mode 100644 index 000000000..517bf4b9b --- /dev/null +++ b/packages/ui-default/components/footer/footer.page.styl @@ -0,0 +1,94 @@ +$footer-extra-link-gap = 20px + +.footer + background: $footer-bg-color + color: $text-1-color + + h1 + font-size: rem($font-size-title) + + +mobile() + padding: rem($grid-padding) + cursor: pointer + font-size: rem($font-size-secondary) + +.footer__category + padding-top: rem(30px) + padding-bottom: rem(50px) + + +mobile() + padding-top: rem($grid-padding) + padding-bottom: rem($grid-padding) + border-bottom: 1px solid darken($footer-bg-color, 5%) + + &.expandable + padding: 0 + + .expand-icon + display: none + + +mobile() + display: inline-block + + &.expanded .expand-icon + transform: rotate(180deg) + +.footer__category__list + margin-top: rem(10px) + margin-bottom: rem(10px) + font-size: rem($font-size-small) + line-height: 2 + + +mobile() + margin-top: 0 + padding-left: rem($grid-padding) + padding-right: rem($grid-padding) + +.footer__category__expander + +mobile() + display: none + + +above(rupture.mobile-cutoff) + display: block !important + +.footer__extra-left + float: left + +.footer__extra-right + float: right + +.footer__extra-link + padding: rem(20px 0) + font-size: rem($font-size-small) + +.footer__extra-link-item + float: left + line-height: 2 + +.footer__extra-left .footer__extra-link-item + margin-right: rem($footer-extra-link-gap) + +.footer__extra-right .footer__extra-link-item + margin-left: rem($footer-extra-link-gap) + +.layout--immersive + .footer + background: none + color: #FFF + + a, a:visited, a:active + color: #FFF + + a:hover + color: #FFF + ++mobile() + .footer__extra-left, .footer__extra-right + float: none + display: block + + .footer__extra-link-item + float: none + display: inline-block + margin-left: 0 + margin-right: $footer-extra-link-gap diff --git a/packages/ui-default/components/form/button.page.styl b/packages/ui-default/components/form/button.page.styl new file mode 100644 index 000000000..fc4064cd0 --- /dev/null +++ b/packages/ui-default/components/form/button.page.styl @@ -0,0 +1,82 @@ +@import './var.inc.styl' + +.button + font-size: rem($font-size-secondary) + line-height: rem($form-control-height - 2) + background-color: lighten($default-color, 50%) + border: 1px solid $default-color + height: rem($form-control-height) + padding: rem(0 25px) + display: inline-block + outline: 0 + margin: 0 0 1rem + transition: background-color .2s, box-shadow .5s + transition-timing-function: ease-out-sine + cursor: pointer + user-select: none + + &, &:visited, &:hover + &, .typo & + color: $text-1-color + + &:hover, &:focus + text-decoration: none + + &:not(.disabled) + &:hover, &:focus + background-color: darken($default-color, 5%) + border-color: darken($default-color, 5%) + box-shadow: 0 4px 15px rgba(darken($default-color, 10%), 0.5) + + &:focus + border-color: $input-focus-color + + &.expanded + padding-left: 0 + padding-right: 0 + text-align: center + width: 100% + + &.rounded + border-radius: rem($form-control-height / 2) + + &.primary + border-color: $primary-color + background-color: $primary-color + + &, &:visited, &:hover + &, .typo & + color: #FFF + + &:not(.disabled) + &:hover + background-color: lighten(saturate($primary-color, 30%), 10%) + border-color: saturate($primary-color, 70%) + box-shadow: 0 4px 15px rgba(saturate($primary-color, 70%), 0.3) + + &.disabled + cursor: default + opacity: 0.5 + + &.compact + margin-bottom: 0 + height: rem($compact-control-height) + line-height: rem($compact-control-height - 2) + padding: rem(0 15px) + +.button.inverse + line-height: rem($form-control-height - 4) + border: 2px solid #FFF + background: none + + &, &:visited + &, .typo & + color: #FFF + + &:not(.disabled) + &:hover, &:focus + border-color: $immersive-primary-color + background: none + box-shadow: none + &, .typo & + color: $immersive-primary-color diff --git a/packages/ui-default/components/form/checkbox.page.styl b/packages/ui-default/components/form/checkbox.page.styl new file mode 100644 index 000000000..889e85591 --- /dev/null +++ b/packages/ui-default/components/form/checkbox.page.styl @@ -0,0 +1,90 @@ +@import './var.inc.styl' + +$checkbox-size = 16px + +.checkbox + font-size: rem($font-size-secondary) + line-height: 1.4 + border: 0 + display: inline-block + vertical-align: middle + cursor: pointer + user-select: none + color: lighten($text-1-color, 30%) + margin: $input-margin + padding: ((rem($form-control-height) - (@line-height * @font-size)) / 2) 0 + + &:hover + color: $text-1-color + + input + appearance: none + border: 1px solid #D0D0D0 + display: inline-block + border-radius: 1px + vertical-align: top + width: rem($checkbox-size) + height: rem($checkbox-size) + margin: ((@line-height * @font-size - rem($checkbox-size)) / 2) 0 + margin-right: rem(5px) + position: relative + outline: 0 + transition: background .1s linear + background: none + cursor: pointer + + &:after + content: '' + width: rem(10px) + height: rem(6px) + position: absolute + top: rem(3px) + left: rem(2px) + border: 2px solid #FFF + border-top: none + border-right: none + background: transparent + transform: rotate(-65deg) scale(0.5) + opacity: 0 + + &:enabled:hover + border-color: rgba($secondary-color, 0.7) + + &:checked + background: $secondary-color + border-color: $secondary-color + + &:checked:after + opacity: 1 + transform: rotate(-45deg) + + &:disabled + opacity: 0.5 + + &.compact + margin-bottom: 0 + line-height: 1.2 + padding: ((rem($compact-control-height) - (@line-height * @font-size)) / 2) 0 + + input + margin: ((@line-height * @font-size - rem($checkbox-size)) / 2) 0 + margin-right: rem(5px) + +label.checkbox.inverse + color: rgba($immersive-text-color, 0.8) + + input + color: $immersive-text-color + border-color: rgba($immersive-text-color, 0.4) + + &:disabled, &:read-only + color: rgba($immersive-text-color, 0.6) + + &:enabled:read-write:hover + border-color: rgba($immersive-text-color, 0.8) + + &:enabled:read-write:focus + border-color: $immersive-primary-color + + &.focus + color: $immersive-primary-color diff --git a/packages/ui-default/components/form/form.page.js b/packages/ui-default/components/form/form.page.js new file mode 100644 index 000000000..98fe0793d --- /dev/null +++ b/packages/ui-default/components/form/form.page.js @@ -0,0 +1,15 @@ +import { AutoloadPage } from 'vj/misc/Page'; + +const formPage = new AutoloadPage('formPage', () => { + $(document).on('vjFormDisableUpdate', 'input, select, textarea', (ev) => { + const $input = $(ev.currentTarget); + const $formItem = $input.closest('.form__item'); + if ($input.prop('disabled')) { + $formItem.addClass('is--disabled'); + } else { + $formItem.removeClass('is--disabled'); + } + }); +}); + +export default formPage; diff --git a/packages/ui-default/components/form/form.page.styl b/packages/ui-default/components/form/form.page.styl new file mode 100644 index 000000000..8ab99bd06 --- /dev/null +++ b/packages/ui-default/components/form/form.page.styl @@ -0,0 +1,199 @@ +// Extracted from Foundation framework + +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ +button, +input, +optgroup, +select, +textarea + color: inherit + /* 1 */ + font: inherit + /* 2 */ + margin: 0 + /* 3 */ + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ +button + overflow: visible + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ +button, +select + text-transform: none + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ +button, +html input[type="button"], +input[type="reset"], +input[type="submit"] + appearance: button + /* 2 */ + cursor: pointer + /* 3 */ + +/** + * Re-set default cursor for disabled elements. + */ +button[disabled], +html input[disabled] + cursor: not-allowed + +/** + * Remove inner padding and border in Firefox 4+. + */ +button::-moz-focus-inner, +input::-moz-focus-inner + border: 0 + padding: 0 + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ +input + line-height: normal + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ +input[type="checkbox"], +input[type="radio"] + box-sizing: border-box + /* 1 */ + padding: 0 + /* 2 */ + +/** + * Fix the cursor style for Chrome's increment/decrement buttons. For certain + * `font-size` values of the `input`, it causes the cursor style of the + * decrement button to change from `default` to `text`. + */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button + height: auto + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome. + */ +input[type="search"] + appearance: textfield + /* 1 */ + box-sizing: content-box + /* 2 */ + +/** + * Remove inner padding and search cancel button in Safari and Chrome on OS X. + * Safari (but not Chrome) clips the cancel button when the search input has + * padding (and `textfield` appearance). + */ +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration + -webkit-appearance: none + +/** + * Define consistent border, margin, and padding. + * [NOTE] We don't enable this ruleset in Foundation, because we want the
element to have plain styling. + */ +/* fieldset + border: 1px solid #c0c0c0 + margin: 0 2px + padding: 0.35em 0.625em 0.75em + */ +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ +legend + border: 0 + /* 1 */ + padding: 0 + /* 2 */ + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ +textarea + overflow: auto + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ +optgroup + font-weight: bold + +.form--inline + display: inline + +textarea + height: auto + border-radius: 0px + resize: vertical + +select + width: 100% + border-radius: 0px + +button + appearance: none + background: transparent + padding: 0 + border: 0 + border-radius: 0px + line-height: 1 + +[data-whatinput='mouse'] button + outline: 0 + +label + display: block + margin: 0 + font-size: rem($font-size-secondary) + line-height: 1.8 + color: $label-color + + input, textarea, select + display: block + +.help-text + margin-top: -0.5rem + margin-bottom: 1rem + font-size: rem($font-size-small) + color: $help-text-color + +.form__item + transition: opacity .1s linear + + &.is--disabled + opacity: 0.3 + + :disabled + opacity: 1 diff --git a/packages/ui-default/components/form/multiSelectCheckbox.page.js b/packages/ui-default/components/form/multiSelectCheckbox.page.js new file mode 100644 index 000000000..f9a6caa67 --- /dev/null +++ b/packages/ui-default/components/form/multiSelectCheckbox.page.js @@ -0,0 +1,45 @@ +import { AutoloadPage } from 'vj/misc/Page'; + +const lastSelectionByGroup = {}; + +function isInGroup(group) { + return (idx, element) => element.getAttribute('data-checkbox-group') === group; +} + +function onRangeRoleCheckboxClick(ev) { + const $current = $(ev.currentTarget); + const targetGroup = $current.attr('data-checkbox-group'); + const $targets = $('[type="checkbox"]') + .filter(isInGroup(targetGroup)) + .filter(':visible') + .filter(':not(:disabled)'); + if (ev.shiftKey && lastSelectionByGroup[targetGroup]) { + const destCheck = lastSelectionByGroup[targetGroup].prop('checked'); + const from = $targets.index(lastSelectionByGroup[targetGroup]); + const to = $targets.index($current); + const start = Math.min(from, to); + const end = Math.max(from, to) + 1; + $targets + .slice(start, end) + .prop('checked', destCheck); + } + lastSelectionByGroup[targetGroup] = $current; +} + +function onToggleRoleCheckboxClick(ev) { + const $current = $(ev.currentTarget); + const targetGroup = $current.attr('data-checkbox-toggle'); + const $targets = $('[type="checkbox"]') + .filter(isInGroup(targetGroup)) + .filter(':visible') + .filter(':not(:disabled)'); + const destCheck = $current.prop('checked'); + $targets.prop('checked', destCheck); +} + +const multiSelectCheckboxPage = new AutoloadPage('multiSelectCheckboxPage', () => { + $(document).on('click', '[data-checkbox-range]', onRangeRoleCheckboxClick); + $(document).on('click', '[data-checkbox-toggle]', onToggleRoleCheckboxClick); +}); + +export default multiSelectCheckboxPage; diff --git a/packages/ui-default/components/form/radiobox.page.styl b/packages/ui-default/components/form/radiobox.page.styl new file mode 100644 index 000000000..e4a9ff6ff --- /dev/null +++ b/packages/ui-default/components/form/radiobox.page.styl @@ -0,0 +1,62 @@ +@import './var.inc.styl' + +$radiobox-size = 16px + +.radiobox + display: block + font-size: rem($font-size-secondary) + line-height: 1.4 + border: 0 + cursor: pointer + user-select: none + color: lighten($text-1-color, 30%) + margin: $input-multi-line-margin + + &:hover + color: $text-1-color + + input + display: inline-block + +.radiobox-container + margin: $input-margin + +// Image Radio Box + +.radiobox.with-image + margin: $input-image-radio-margin + + .radiobox__image-container + cursor: pointer + border: 2px solid #DDD + transition: border-color .1s linear, opacity .1s linear + padding: 1px + overflow: hidden + position: relative + opacity: 0.7 + + &:hover + border: 2px solid $primary-color + opacity: 1 + + &:after + position: absolute + width: rem(20px) + height: rem(20px) + right: rem(-10px) + top: rem(-10px) + transform: translateX(10px) translateY(-10px) rotate(45deg) + background: $highlight-color + content: '' + transition: transform .1s ease-out-cubic + + input + visibility: hidden + position: absolute + + &:checked + .radiobox__image-container + border: 2px solid $highlight-color + opacity: 1 + + &:after + transform: rotate(45deg) diff --git a/packages/ui-default/components/form/select.page.styl b/packages/ui-default/components/form/select.page.styl new file mode 100644 index 000000000..125cdf03f --- /dev/null +++ b/packages/ui-default/components/form/select.page.styl @@ -0,0 +1,29 @@ +@import './var.inc.styl' + +.select + form-styles() + padding-right: 1.1rem + background-color: $input-background-color + background-image: url('~vj/misc/icons/' + $icon-expand_more-file) + background-size: 16px 16px + background-position: right -1rem center + background-origin: content-box + background-repeat: no-repeat + outline: $input-outline + transition: outline-color .2s, border-color .2s + transition-timing-function: ease-out-cubic + + &:focus + border-color: $input-focus-border-color + outline: $input-focus-outline + outline-offset: 0 + + &:disabled + opacity: 0.5 + +.select-container.compact .select, .select.compact + margin-bottom: 0 + height: rem($compact-control-height) + line-height: rem($compact-control-height - 2) + padding-top: 0 + padding-bottom: 0 diff --git a/packages/ui-default/components/form/textbox.page.js b/packages/ui-default/components/form/textbox.page.js new file mode 100644 index 000000000..b6824af0c --- /dev/null +++ b/packages/ui-default/components/form/textbox.page.js @@ -0,0 +1,20 @@ +import { AutoloadPage } from 'vj/misc/Page'; + +const textboxPage = new AutoloadPage('textboxPage', () => { + $(document).on('focusin', '.textbox.material input', (ev) => { + $(ev.currentTarget).parent().addClass('focus'); + }); + + $(document).on('focusout', '.textbox.material input', (ev) => { + $(ev.currentTarget).parent().removeClass('focus'); + }); + + const $focusElement = $(document.activeElement); + if ($focusElement.prop('tagName') === 'INPUT' + && $focusElement.parent().is('.textbox.material') + ) { + $focusElement.focusin(); + } +}); + +export default textboxPage; diff --git a/packages/ui-default/components/form/textbox.page.styl b/packages/ui-default/components/form/textbox.page.styl new file mode 100644 index 000000000..feda6bc00 --- /dev/null +++ b/packages/ui-default/components/form/textbox.page.styl @@ -0,0 +1,72 @@ +@import './var.inc.styl' + +// Normal Textbox +input.textbox, textarea.textbox + form-styles() + background-color: $input-background-color + outline: $input-outline + transition: outline-color .2s, border-color .2s + transition-timing-function: ease-out-cubic + + &:focus + border-color: $input-focus-border-color + outline: $input-focus-outline + outline-offset: 0 + + // Overwrite, for IE compatibility + &:read-only + outline: 0 + + &:disabled + opacity: 0.5 + +textarea.textbox + height: rem(200px) + +.data-table + input.textbox, textarea.textbox + margin-bottom: 0 + +// Material Textbox +label.textbox.material + input + form-styles() + border: 0 + background: none + outline: 0 + padding-left: 0 + padding-right: 0 + color: $text-1-color + border-bottom: 2px solid rgba($text-1-color, 0.4) + transition: border-color .2s ease-out-cubic + + &:disabled, &:read-only + color: rgba($text-1-color, 0.6) + + &:enabled:read-write:hover + border-color: rgba($text-1-color, 0.8) + + &:enabled:read-write:focus + border-color: $input-focus-border-color + + &.focus + color: $input-focus-border-color + +label.textbox.material.inverse + color: rgba($immersive-text-color, 0.8) + + input + color: $immersive-text-color + border-color: rgba($immersive-text-color, 0.4) + + &:disabled, &:read-only + color: rgba($immersive-text-color, 0.6) + + &:enabled:read-write:hover + border-color: rgba($immersive-text-color, 0.8) + + &:enabled:read-write:focus + border-color: $immersive-primary-color + + &.focus + color: $immersive-primary-color diff --git a/packages/ui-default/components/form/var.inc.styl b/packages/ui-default/components/form/var.inc.styl new file mode 100644 index 000000000..65117e174 --- /dev/null +++ b/packages/ui-default/components/form/var.inc.styl @@ -0,0 +1,14 @@ +form-styles() + appearance: none + display: block + width: 100% + font-size: rem($font-size-secondary) + margin: $input-margin + height: rem($form-control-height) + line-height: 1.2 + padding: rem(5px) + border: $input-border + + &.inline + display: inline-block + width: auto diff --git a/packages/ui-default/components/header/header-background.png b/packages/ui-default/components/header/header-background.png new file mode 100644 index 000000000..ae4c578af Binary files /dev/null and b/packages/ui-default/components/header/header-background.png differ diff --git a/packages/ui-default/components/header/header-background@2x.png b/packages/ui-default/components/header/header-background@2x.png new file mode 100644 index 000000000..8d6d2692e Binary files /dev/null and b/packages/ui-default/components/header/header-background@2x.png differ diff --git a/packages/ui-default/components/header/header-logo-summer.png b/packages/ui-default/components/header/header-logo-summer.png new file mode 100644 index 000000000..eaf4fda4c Binary files /dev/null and b/packages/ui-default/components/header/header-logo-summer.png differ diff --git a/packages/ui-default/components/header/header-logo-summer@2x.png b/packages/ui-default/components/header/header-logo-summer@2x.png new file mode 100644 index 000000000..e4ab84b54 Binary files /dev/null and b/packages/ui-default/components/header/header-logo-summer@2x.png differ diff --git a/packages/ui-default/components/header/header-logo.png b/packages/ui-default/components/header/header-logo.png new file mode 100644 index 000000000..13dab1386 Binary files /dev/null and b/packages/ui-default/components/header/header-logo.png differ diff --git a/packages/ui-default/components/header/header-logo@2x.png b/packages/ui-default/components/header/header-logo@2x.png new file mode 100644 index 000000000..9e032946c Binary files /dev/null and b/packages/ui-default/components/header/header-logo@2x.png differ diff --git a/packages/ui-default/components/header/header.page.styl b/packages/ui-default/components/header/header.page.styl new file mode 100644 index 000000000..c28d262f6 --- /dev/null +++ b/packages/ui-default/components/header/header.page.styl @@ -0,0 +1,176 @@ +$header-gap-mini = ($header-bg-height-mini - $nav-item-height - $header-logo-height) / 2 +$header-gap-mini-domain = ($header-bg-height-mini - $nav-item-height - $header-logo-domain-lh - $header-logo-system-lh) / 2 + +.global-notification + padding: rem(20px 0) + background: #FFF + + &.warning + background: #ffe1a6 + +.header__options + background-image: url(./header-background.png) + background-image: url(./header-background@2x.png) + +.header + position: relative + height: rem($header-bg-height) + background: #5eafca no-repeat center top + background-size: cover + transition: height .5s + transition-timing-function: ease-out-cubic + overflow: hidden + +retina() + background-size: rem($header-bg-width $header-bg-height) + + &.mini + height: rem($header-bg-height-mini) + + +mobile() + background: #FFF + height: auto !important + .header__content + display: none + +.header__logo__options + background-image: url(./header-logo.png) + background-image: url(./header-logo@2x.png) + background-image: url(./header-logo-summer.png) + background-image: url(./header-logo-summer@2x.png) + +.header__logo + display: block + width: rem($header-logo-width-real) + height: rem($header-logo-height + $header-gap-mini + $nav-item-height) + background: no-repeat + background-size: rem($header-logo-width-real) rem($header-logo-height-real) + background-position: 0 rem($header-gap-mini + $nav-item-height - ($header-logo-height-real - $header-logo-height)) + transition: transform .5s, opacity .5s + transition-timing-function: ease-out-cubic + transform-origin: 0 0 + opacity: 1 + +mobile() + display: inline-block + +.header__logo--domain + font-size: rem(40px) + margin-top: rem($header-gap-mini-domain + $nav-item-height) + height: rem($header-logo-domain-lh) + line-height: rem($header-logo-domain-lh) + + a + text-shadow: 1px 1px 5px rgba(#000, 0.4) + + &, &:hover, &:visited, &:link + color: #FFF + text-decoration: none + +.header__logo--system + line-height: rem($header-logo-system-lh) + color: #FFF + font-size: rem($font-size-small) + + a + opacity: 0.8 + + &, &:hover, &:visited, &:link + text-decoration: none + color: #FFF + + &:hover + opacity: 1 + text-decoration: underline + +.header--collapsed + + #panel + padding-top: $header-bg-height + + .header + position: absolute + left: 0 + top: 0 + width: 100% + height: 0 + + .header__logo + transform: scale(0) + opacity: 0 + +.location + position: absolute + left: 0 + bottom: 0 + width: 100% + height: rem($header-layer-height) + background: rgba(#000, 0.5) + + +mobile() + position: relative + height: auto + + .header__hamburger + display: none + + +mobile() + display: block + +.header.mini .location + display: none + +.location-path + color: rgba(#FFF, 0.3) + line-height: rem(30px) + margin-top: rem(10px) + a, a:visited + color: rgba(#FFF, 0.6) + a:hover + color: rgba(#FFF, 0.9) + +.location-current + color: #9ad4f1 + line-height: rem(40px) + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + + +mobile() + color: #91fcff + font-size: 1.7rem + white-space: normal + +.header--mobile + display: none + +.header--mobile, .location + +mobile() + display: block + background: #56758f + color: #FFF + line-height: rem($nav-item-height) + font-size: rem($font-size-title) + padding: rem(10px 0) + + a, a:visited, a:hover + color: #FFF + +.header--mobile__domain.is--system + display: block + width: $nav-logo-width // no rem + height: $nav-logo-height // no rem + margin-top: rem(10px) + +retina() + background-size: $nav-logo-width $nav-logo-height // no rem + +.header--mobile__system + margin-left: rem(10px) + font-size: rem($font-size-secondary) + + &, &:hover, &:visited + color: $supplementary-color + +.header__hamburger + background: none + border: 0 + border-radius: 0 + outline: 0 diff --git a/packages/ui-default/components/highlighter/codemirror.page.styl b/packages/ui-default/components/highlighter/codemirror.page.styl new file mode 100644 index 000000000..c18494ba8 --- /dev/null +++ b/packages/ui-default/components/highlighter/codemirror.page.styl @@ -0,0 +1,12 @@ +@css { +span.cm-comment { color: #008000; } +span.cm-keyword, span.cm-variable-3 { line-height: 1em; color: #00f; } +span.cm-string { color: #a31515; } +span.cm-builtin { line-height: 1em; font-weight: bold; color: #077; } +span.cm-special { line-height: 1em; font-weight: bold; color: #0aa; } +span.cm-variable { color: black; } +span.cm-meta { color: #2b91af; } +span.cm-link { color: #3a3; } +.CodeMirror-activeline-background { background: #e8f2ff; } +.CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } +} diff --git a/packages/ui-default/components/highlighter/highlighter.page.js b/packages/ui-default/components/highlighter/highlighter.page.js new file mode 100644 index 000000000..1e9bfcc04 --- /dev/null +++ b/packages/ui-default/components/highlighter/highlighter.page.js @@ -0,0 +1,14 @@ +import { AutoloadPage } from 'vj/misc/Page'; + +const highlighterPage = new AutoloadPage('highlighterPage', () => { + import('./prismjs').then((module) => { + const prismjs = module.default; + function runHighlight($container) { + prismjs.highlightBlocks($container); + } + runHighlight($('body')); + $(document).on('vjContentNew', (e) => runHighlight($(e.target))); + }); +}); + +export default highlighterPage; diff --git a/packages/ui-default/components/highlighter/highlighter.page.styl b/packages/ui-default/components/highlighter/highlighter.page.styl new file mode 100644 index 000000000..b083d40ba --- /dev/null +++ b/packages/ui-default/components/highlighter/highlighter.page.styl @@ -0,0 +1,122 @@ +/* + +VS theme by Andrew Lock + +https://github.com/PrismJS/prism-themes/blob/master/themes/prism-vs.css + +*/ + +pre, code + background: $syntax-hl-bg-color + +pre > code + background: none + line-height: 1.5 + +pre + position: relative + padding: rem(5px) + +code + color: $code-color + font-size: rem($font-size-secondary) + +pre > code + color: black + text-align: left + white-space: pre-wrap + word-spacing: normal + word-break: normal + tab-size: 4 + hyphens: none + +[data-syntax-hl-show-line-number] pre.line-numbers > code + white-space: pre + +.syntax-hl + .token + &.comment, &.prolog, &.doctype, &.cdata + color: #008000 + font-style: italic + &.namespace + opacity: 0.7 + &.string + color: #a31515 + &.punctuation, &.operator + color: #393A34 + &.url, &.symbol, &.number, &.boolean, &.inserted + color: #36acaa + &.atrule, &.keyword, &.attr-value, .language-autohotkey&.selector, .language-json&.boolean, .language-json&.number + color: #0000ff + &.function + color: #393A34 + &.deleted, .language-autohotkey&.tag + color: #9a050f + &.selector, .language-autohotkey&.keyword + color: #00009f + &.important, &.bold + font-weight: bold + &.italic + font-style: italic + &.class-name, .language-json&.property + color: #2B91AF + &.tag, &.selector + color: #800000 + &.attr-name, &.property, &.regex, &.entity + color: #ff0000 + &.directive.tag .tag + background: #ffff00 + color: #393A34 + &.macro + &, .token + color: #2b91af + &.builtin + color: #077 + + .toolbar + position: absolute + white-space: no-wrap + right: 0 + top: 0 + padding: rem(5px) + background: rgba($syntax-hl-bg-color, 0.8) + font-family: $primary-font-family + font-size: rem($font-size-small) + opacity: 0 + pointer-events: none + transition: opacity .1s linear + user-select: none + + &:hover .toolbar + opacity: 1 + pointer-events: all + +[data-syntax-hl-show-line-number] + pre + padding-left: 3.8em + counter-reset: linenumber + + pre > code + position: relative + + .line-numbers-rows + position: absolute + pointer-events: none + top: 0 + font-size: 100% + left: -3.8em + width: 3em + letter-spacing: -1px + user-select: none + + span + pointer-events: none + display: block + counter-increment: linenumber + + &:before + content: counter(linenumber) + color: #ddd + display: block + padding-right: 0.8em + text-align: right diff --git a/packages/ui-default/components/highlighter/meta.js b/packages/ui-default/components/highlighter/meta.js new file mode 100644 index 000000000..3939c94a8 --- /dev/null +++ b/packages/ui-default/components/highlighter/meta.js @@ -0,0 +1,15 @@ +// The following content is extracted from: +// https://github.com/codemirror/CodeMirror/blob/master/mode/meta.js + +export default [ + { name: 'C', ext: ['c', 'h'] }, + { name: 'C++', ext: ['cpp', 'c++', 'cc', 'cxx', 'hpp', 'h++', 'hh', 'hxx'] }, + { name: 'Go', ext: ['go'] }, + { name: 'Haskell', ext: ['hs'] }, + { name: 'Java', ext: ['java'] }, + { name: 'JavaScript', ext: ['js'] }, + { name: 'Pascal', ext: ['p', 'pas'] }, + { name: 'PHP', ext: ['php', 'php3', 'php4', 'php5', 'php7', 'phtml'] }, + { name: 'Python', ext: ['BUILD', 'bzl', 'py', 'pyw'] }, + { name: 'Rust', ext: ['rs'] }, +]; diff --git a/packages/ui-default/components/highlighter/prismjs.js b/packages/ui-default/components/highlighter/prismjs.js new file mode 100644 index 000000000..273dc1344 --- /dev/null +++ b/packages/ui-default/components/highlighter/prismjs.js @@ -0,0 +1,76 @@ +/* + +To add a new language to highlight: +1. Add language in babel in package.json +2. Add new import statement in `components/cmeditor/vjcmeditor.js` +3. Add new import statement in `components/scratchpad/ScratchpadEditorContainer.js` +4. Add new meta data in `components/highlighter/meta.js` + + */ + +import Prism from 'prismjs'; + +import Clipboard from 'clipboard'; +import Notification from 'vj/components/notification'; +import i18n from 'vj/utils/i18n'; + +import languageMeta from './meta'; + +const languageExtMap = {}; + +// Map possible language names to Prism language name +languageMeta.forEach((meta) => { + for (let i = 0; i < meta.ext.length; ++i) { + if (Prism.languages[meta.ext[i]] !== undefined) { + // eslint-disable-next-line no-param-reassign + meta.target = meta.ext[i]; + break; + } + } + meta.ext.forEach((ext) => { + languageExtMap[ext] = meta.target; + }); +}); + +// Copy to Clipboard +try { + Prism.plugins.toolbar.registerButton('copy-to-clipboard', (env) => { + const linkCopy = document.createElement('a'); + linkCopy.href = 'javascript:;'; // eslint-disable-line no-script-url + linkCopy.textContent = 'Copy'; + const clip = new Clipboard(linkCopy, { text: () => env.code }); + clip.on('success', () => { + Notification.success(i18n('Content copied to clipboard!'), 1000); + }); + clip.on('error', () => { + Notification.error(i18n('Copy failed :(')); + }); + return linkCopy; + }); +} catch (e) { + // snowpack +} + +const prismjsApiWrap = { + highlightBlocks: ($dom) => { + $dom.find('pre code').get().forEach((code) => { + const $pre = $(code).parent(); + $pre.addClass('syntax-hl'); + if ($pre.closest('[data-syntax-hl-show-line-number]')) { + $pre.addClass('line-numbers'); + } + // try to map the language name + const language = $(code).attr('class'); + const m = (language || '').trim().match(/^language-(.+)$/); + if (m && m[1]) { + const languageName = m[1].toLowerCase(); + if (languageExtMap[languageName]) { + $(code).attr('class', `language-${languageExtMap[languageName]}`); + } + } + Prism.highlightElement(code); + }); + }, +}; + +export default prismjsApiWrap; diff --git a/packages/ui-default/components/hitokoto/index.page.js b/packages/ui-default/components/hitokoto/index.page.js new file mode 100644 index 000000000..b738d8553 --- /dev/null +++ b/packages/ui-default/components/hitokoto/index.page.js @@ -0,0 +1,28 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import request from 'vj/utils/request'; +import i18n from 'vj/utils/i18n'; +import tpl from 'vj/utils/tpl'; + +const hitokotoPage = new AutoloadPage('hitokotoPage', () => { + function getHitokoto($containers) { + $containers.get().forEach((container) => { + request.get('https://v1.hitokoto.cn?c=a&c=b&c=c&c=d&c=e&c=f') + .then((hitokoto) => { + const dom = $(tpl`

${hitokoto.hitokoto}

`); + dom.appendTo(container); + }) + .catch((e) => { + console.error(e); + const dom = $(tpl`

${i18n('Cannot get hitokoto.')}

`); + dom.appendTo(container); + }); + }); + } + if ($('[name="hitokoto"]')) getHitokoto($('[name="hitokoto"]')); + $(document).on('vjContentNew', (e) => { + const elem = $(e.target).find('[name="hitokoto"]'); + if (elem.get) getHitokoto(elem); + }); +}); + +export default hitokotoPage; diff --git a/packages/ui-default/components/hotkey/hotkey.page.js b/packages/ui-default/components/hotkey/hotkey.page.js new file mode 100644 index 000000000..ab174d564 --- /dev/null +++ b/packages/ui-default/components/hotkey/hotkey.page.js @@ -0,0 +1,80 @@ +import _ from 'lodash'; +import { AutoloadPage } from 'vj/misc/Page'; + +const KEY_MAP = { + 10: 'enter', + 13: 'enter', + 27: 'esc', + 37: 'left', + 38: 'up', + 39: 'right', + 40: 'down', + 112: 'f1', + 113: 'f2', + 114: 'f3', + 115: 'f4', + 116: 'f5', + 117: 'f6', + 118: 'f7', + 119: 'f8', + 120: 'f9', + 121: 'f10', + 122: 'f11', + 123: 'f12', +}; + +function isHotkeyMatch(sortedHotkeyArr, hotkeyStr) { + const hotkeyDefined = hotkeyStr.toLowerCase().split('+'); + return _.isEqual(sortedHotkeyArr, hotkeyDefined.sort()); +} + +function testElementHotkey(hotkey, $element, attr) { + if (!$element.is(':visible')) { + return; + } + String($element.attr(attr)) + .split(',') + .forEach((singleDef) => { + const [defStr, trigger] = singleDef.split(':'); + if (isHotkeyMatch(hotkey, defStr)) { + switch (trigger) { + case 'submit': + $element.closest('form').trigger('submit'); + break; + case undefined: + $element.trigger('click'); + break; + default: + $element.trigger(trigger); + break; + } + } + }); +} + +const hotkeyPage = new AutoloadPage('hotkeyPage', () => { + $(document).on('keydown', (ev) => { + const hotkey = ['alt', 'ctrl', 'shift'].filter((modifyKey) => ev[`${modifyKey}Key`]); + if (ev.metaKey && !ev.ctrlKey) { + hotkey.push('ctrl'); + } + if (KEY_MAP[ev.which] !== undefined) { + hotkey.push(KEY_MAP[ev.which]); + } else { + hotkey.push(String.fromCharCode(ev.which).toLowerCase()); + } + hotkey.sort(); + + // Find all global hotkeys + $('[data-global-hotkey]').get().forEach((element) => { + testElementHotkey(hotkey, $(element), 'data-global-hotkey'); + }); + + // Find all local hotkeys + $(ev.target).parents('[data-hotkey]').get().forEach((element) => { + testElementHotkey(hotkey, $(element), 'data-hotkey'); + }); + }); +}); + +export default hotkeyPage; diff --git a/packages/ui-default/components/katex/katex.page.js b/packages/ui-default/components/katex/katex.page.js new file mode 100644 index 000000000..5ed4df5eb --- /dev/null +++ b/packages/ui-default/components/katex/katex.page.js @@ -0,0 +1,13 @@ +import { AutoloadPage } from 'vj/misc/Page'; + +const katexPage = new AutoloadPage('katexPage', () => { + import('katex/dist/contrib/auto-render.min.js').then(({ default: katex }) => { + function runKatex($containers) { + $containers.get().forEach((container) => katex(container)); + } + runKatex($('.typo')); + $(document).on('vjContentNew', (e) => runKatex($(e.target).find('.typo'))); + }); +}); + +export default katexPage; diff --git a/packages/ui-default/components/katex/katex.styl b/packages/ui-default/components/katex/katex.styl new file mode 100644 index 000000000..9d6fb1608 --- /dev/null +++ b/packages/ui-default/components/katex/katex.styl @@ -0,0 +1,2 @@ +.katex + font-size: 1.2em diff --git a/packages/ui-default/components/loader/loader.page.styl b/packages/ui-default/components/loader/loader.page.styl new file mode 100644 index 000000000..a243a9239 --- /dev/null +++ b/packages/ui-default/components/loader/loader.page.styl @@ -0,0 +1,29 @@ +.loader-container + position: absolute + left: 0 + width: 100% + top: 0 + height: 100% + +.loader + text-indent: -9999em + border: rem(5px) solid rgba($primary-color, .2) + border-left: rem(5px) solid $primary-color + animation: load8 1.1s infinite linear + border-radius: 50% + width: rem(50px) + height: rem(50px) + margin-left: rem(-25px) + margin-top: rem(-25px) + left: 50% + top: 50% + position: absolute + +@keyframes load8 + 0% + transform: rotate(0deg) + + 100% + transform: rotate(360deg) + + diff --git a/packages/ui-default/components/marker/Marker.js b/packages/ui-default/components/marker/Marker.js new file mode 100644 index 000000000..c701edbaa --- /dev/null +++ b/packages/ui-default/components/marker/Marker.js @@ -0,0 +1,193 @@ +import _ from 'lodash'; +import delay from 'vj/utils/delay'; +import Tooltip from 'vj/components/tooltip/Tooltip'; +import i18n from 'vj/utils/i18n'; + +const MARKER_ID = `marker_${Math.floor(Math.random() * 0xFFFFFF).toString(16)}`; +const MARKER_OFFSET = 20; +const MARKER_MAX_DISTANCE = 60; + +let markerInstance = null; + +function distanceToRect(px, py, rect) { + const cx = (rect.left + rect.right) / 2; + const cy = (rect.top + rect.bottom) / 2; + const dx = Math.max(Math.abs(px - cx) - (rect.right - rect.left) / 2, 0); + const dy = Math.max(Math.abs(py - cy) - (rect.bottom - rect.top) / 2, 0); + return Math.sqrt(dx * dx + dy * dy); +} + +class Marker { + static exists() { + return markerInstance && document.getElementById(MARKER_ID); + } + + constructor() { + if (Marker.exists()) { + return markerInstance; + } + if (markerInstance) { + markerInstance.destroy(); + } + this.$dom = $(` +
+
+
+
+
+ `) + .appendTo('body'); + this.$dom.find('.marker__toolbar').on('click', '.marker__action', this.onMarkerActionClick.bind(this)); + this.$dom.on('mousedown', this.onMarkerMouseDown.bind(this)); + this.isOpen = false; + this.bindedHandlers = false; + this._onKeyDown = this.onKeyDown.bind(this); + this._onScroll = _.throttle(this.onScroll.bind(this), 50); + this._onMouseDown = this.onMouseDown.bind(this); + this._onMouseMove = _.throttle(this.onMouseMove.bind(this), 50); + markerInstance = this; + } + + markSelection(color) { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + this.$container.attr('contentEditable', true); + if (range) { + // recover ranges + selection.removeAllRanges(); + selection.addRange(range); + } + if (!document.execCommand('HiliteColor', false, color)) { + document.execCommand('BackColor', false, color); + } + this.$container.removeAttr('contentEditable'); + if (range) { + // remove ranges after marking + selection.removeAllRanges(); + } + } + + showAtPosition($container, x, y) { + this.$container = $container; + this.$dom.css({ + display: 'block', + opacity: 0, + }); + const rect = this.$dom[0].getBoundingClientRect(); + this.$dom.css({ + left: x + MARKER_OFFSET, + top: y - (rect.bottom - rect.top) - MARKER_OFFSET, + }); + this.isOpen = true; + this.$dom.addClass('open'); + this.updateMarkerOpacity(x, y); + this.bindEventHandlersForClosing(); + } + + async close() { + if (!this.isOpen) { + return; + } + this.isOpen = false; + this.closeTooltipsImmediately(); + this.unbindEventHandlers(); + this.$dom + .css({ opacity: 0 }) + .removeClass('open'); + await delay(200); + if (!this.isOpen) { + this.$dom.hide(); + } + } + + closeTooltipsImmediately() { + const $elements = this.$dom.find('[data-tooltip]'); + $elements.get().forEach((element) => { + const $el = $(element); + const tooltipInstance = Tooltip.get($el); + if (tooltipInstance) { + tooltipInstance.detach(); + } + }); + } + + bindEventHandlersForClosing() { + if (this.bindedHandlers) { + return; + } + $(document).on('keydown', this._onKeyDown); + $(window).on('scroll', this._onScroll); + $(document).on('mousedown', this._onMouseDown); + $(document).on('mousemove', this._onMouseMove); + this.bindedHandlers = true; + } + + unbindEventHandlers() { + if (!this.bindedHandlers) { + return; + } + $(document).off('keydown', this._onKeyDown); + $(window).off('scroll', this._onScroll); + $(document).off('mousedown', this._onMouseDown); + $(document).off('mousemove', this._onMouseMove); + this.bindedHandlers = false; + } + + onMarkerActionClick(ev) { + const color = $(ev.currentTarget).attr('data-color'); + this.markSelection(color); + this.close(); + } + + onMarkerMouseDown(ev) { + ev.stopPropagation(); + ev.preventDefault(); + } + + onKeyDown() { + this.close(); + } + + onScroll() { + this.close(); + } + + onMouseMove(ev) { + this.updateMarkerOpacity(ev.clientX, ev.clientY); + } + + onMouseDown() { + this.close(); + } + + updateMarkerOpacity(x, y) { + const markerRect = this.$dom[0].getBoundingClientRect(); + const distance = distanceToRect(x, y, markerRect); + if (distance > MARKER_MAX_DISTANCE) { + this.close(); + return; + } + this.$dom.css('opacity', 1 - (distance / MARKER_MAX_DISTANCE)); + } + + destroy() { + this.close(); + this.$dom.remove(); + markerInstance = null; + } +} + +const MarkerInterface = { + close() { + if (!markerInstance) { + return; + } + markerInstance.close(); + }, + showAtPosition($container, x, y) { + const marker = new Marker(); + marker.showAtPosition($container, x, y); + }, +}; + +export default MarkerInterface; diff --git a/packages/ui-default/components/marker/MarkerReactive.js b/packages/ui-default/components/marker/MarkerReactive.js new file mode 100644 index 000000000..897c7cca5 --- /dev/null +++ b/packages/ui-default/components/marker/MarkerReactive.js @@ -0,0 +1,58 @@ +import _ from 'lodash'; + +import delay from 'vj/utils/delay'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import Marker from './Marker'; + +export default class MarkerReactive extends DOMAttachedObject { + static DOMAttachKey = 'vjMarkerReactiveInstance'; + + static initFromDOM($dom) { + return MarkerReactive.getOrConstruct($dom); + } + + static initAll() { + $('[data-marker-enabled]').get().forEach((dom) => MarkerReactive.initFromDOM($(dom))); + } + + constructor($target) { + super($target); + this._onMouseUp = this.onMouseUp.bind(this); + this.bindEventHandlers(); + } + + bindEventHandlers() { + this.$dom.on('mouseup', this._onMouseUp); + } + + unbindEventHandlers() { + this.$dom.off('mouseup', this._onMouseUp); + } + + async onMouseUp(ev) { + await delay(1); + if (!window.getSelection) { + return; + } + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + const range = selection.getRangeAt(0); + if (range.collapsed) { + return; + } + Marker.showAtPosition(this.$dom, ev.clientX, ev.clientY); + } + + detach() { + if (this.detached) { + return; + } + this.unbindEventHandlers(); + Marker.close(); + super.detach(); + } +} + +_.assign(MarkerReactive, DOMAttachedObject); diff --git a/packages/ui-default/components/marker/marker.page.js b/packages/ui-default/components/marker/marker.page.js new file mode 100644 index 000000000..e727bfe3a --- /dev/null +++ b/packages/ui-default/components/marker/marker.page.js @@ -0,0 +1,8 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import MarkerReactive from './MarkerReactive'; + +const markerPage = new AutoloadPage('markerPage', () => { + MarkerReactive.initAll(); +}); + +export default markerPage; diff --git a/packages/ui-default/components/marker/marker.page.styl b/packages/ui-default/components/marker/marker.page.styl new file mode 100644 index 000000000..33fc3c3c1 --- /dev/null +++ b/packages/ui-default/components/marker/marker.page.styl @@ -0,0 +1,54 @@ +[data-marker-enabled] + outline: 0 + +.marker + position: fixed + z-index: 100 + display: none + box-shadow: 0 2px 7px rgba(#000, 0.3) + background: #FFF + font-size: rem($font-size-small) + transition: transform .1s ease-out, opacity .1s ease-out + transform: scale(0.8) + transform-origin: 0 100% + pointer-events: none + background: #FFF + padding: 1px + border: 1px solid #AAA + + &.open + transform: none + pointer-events: auto + + &__toolbar + padding: 1px + background: $toolbar-bg + white-space: nowrap + font-size: 0 + + &__action + display: inline-block + text-align: center + font-size: rem($font-size-icon) + vertical-align: top + cursor: pointer + padding: rem(8px) + color: $toolbar-fore + position: relative + + &:hover + background: $toolbar-bg-hover + color: $toolbar-fore-hover + + &__icon + display: block + margin: rem(2px) + width: rem(12px) + height: rem(12px) + border-radius: 50% + + &.icon-yellow + background: #ffc100 + + &.icon-green + background: #54d651 diff --git a/packages/ui-default/components/menu/menu-heading.page.js b/packages/ui-default/components/menu/menu-heading.page.js new file mode 100644 index 000000000..8f3e07f98 --- /dev/null +++ b/packages/ui-default/components/menu/menu-heading.page.js @@ -0,0 +1,28 @@ +import { AutoloadPage } from 'vj/misc/Page'; + +import tpl from 'vj/utils/tpl'; + +const menuHeadingPage = new AutoloadPage('menuHeadingPage', null, () => { + $('[data-heading-extract-to]').get().forEach((container) => { + const $container = $(container); + const $target = $('body').find($container.attr('data-heading-extract-to')); + if ($target.length === 0) return; + let $menu = $target.children('.menu'); + if ($menu.length === 0) { + $menu = $(tpl``).appendTo($target); + $target.children('.menu__link').addClass('expandable'); + } + $container.find('[data-heading]').get().forEach((heading) => { + const $heading = $(heading); + $(tpl` + + `).appendTo($menu); + }); + }); +}); + +export default menuHeadingPage; diff --git a/packages/ui-default/components/menu/menu.page.js b/packages/ui-default/components/menu/menu.page.js new file mode 100644 index 000000000..23b1de448 --- /dev/null +++ b/packages/ui-default/components/menu/menu.page.js @@ -0,0 +1,18 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import { slideDown } from 'vj/utils/slide'; +import delay from 'vj/utils/delay'; + +function expandMenu($menu) { + slideDown($menu, 500, { opacity: 0 }, { opacity: 1 }); +} + +async function expandAllMenus() { + await delay(200); + $('.menu.collapsed').get().forEach((menu) => expandMenu($(menu))); +} + +const menuPage = new AutoloadPage('menuPage', () => { + expandAllMenus(); +}); + +export default menuPage; diff --git a/packages/ui-default/components/menu/menu.page.styl b/packages/ui-default/components/menu/menu.page.styl new file mode 100644 index 000000000..4b301cc37 --- /dev/null +++ b/packages/ui-default/components/menu/menu.page.styl @@ -0,0 +1,65 @@ +.menu + padding: rem(5px 0) + font-size: rem($font-size-secondary) + + .icon + font-size: rem($font-size-icon) + display: inline-block + margin-right: rem(5px) + line-height: rem(18px) + vertical-align: top + + .hasjs &.collapsed + display: none + +.menu__item .menu + padding: 0 + +.menu__link + outline: 0 + text-align: left + display: block + width: 100% + padding: rem(12px $section-gap-h) + line-height: rem(18px) + color: $text-1-color + border-left: 3px solid transparent + position: relative + + &.highlight + background: mix($highlight-color, #FFF, 10%) + + &.expandable:after + font-family: $icon-font-name + font-size: rem($font-size-icon) + content: $icon-expand_more + position: absolute + right: $section-gap-h + top: 50% + line-height: 1 + margin-top: rem($font-size-icon / -2) + transition: transform .2s ease-out-cubic + opacity: 0.85 + +a.menu__link, button.menu__link + &.disabled + cursor: default + opacity: 0.5 + &:hover + text-decoration: none + &:not(.disabled):hover + color: $text-1-color + background: $menu-item-bg-color-hover + border-color: darken($primary-color, 10%) + &.active, &.active:hover + border-color: darken($secondary-color, 30%) + background: $secondary-color + color: #FFF + +.menu__item .menu .menu__link + padding-left: rem($section-gap-h + 10px) + +.menu__seperator + height: 1px + border-top: 1px solid #EEE + margin: rem(5px 0) diff --git a/packages/ui-default/components/message/index.page.js b/packages/ui-default/components/message/index.page.js new file mode 100644 index 000000000..65f90c95d --- /dev/null +++ b/packages/ui-default/components/message/index.page.js @@ -0,0 +1,38 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import { InfoDialog } from 'vj/components/dialog'; +import Notification from 'vj/components/notification'; +import { FLAG_ALERT } from 'vj/constant/message'; +import i18n from 'vj/utils/i18n'; +import tpl from 'vj/utils/tpl'; + +const messagePage = new AutoloadPage('messagePage', () => { + async function init() { + const { default: SockJs } = await import('../socket'); + + const sock = new SockJs('/home/messages-conn'); + sock.onmessage = (message) => { + const msg = JSON.parse(message.data); + if (msg.mdoc.flag | FLAG_ALERT) { + // Is alert + const dialog = new InfoDialog({ + $body: tpl` +
+

${i18n(msg.udoc.content)}

+
`, + }); + dialog.open(); + } else { + // Is message + Notification.show({ + title: msg.udoc.uname, + avatar: msg.udoc.gravatar, + message: msg.mdoc.content, + }); + } + }; + } + + if (UserContext._id !== 0) init(); +}); + +export default messagePage; diff --git a/packages/ui-default/components/messagepad/DialogueListItem.page.styl b/packages/ui-default/components/messagepad/DialogueListItem.page.styl new file mode 100644 index 000000000..f8ef155ed --- /dev/null +++ b/packages/ui-default/components/messagepad/DialogueListItem.page.styl @@ -0,0 +1,28 @@ +$stripe-width = 2px + +.messagepad__list-item + display: block + padding: rem(20px $section-gap-h 20px ($section-gap-h - $stripe-width)) + border-left: $stripe-width solid transparent + cursor: pointer + transition: border-color .1s linear, background-color .1s linear + overflow: hidden + text-overflow: ellipsis + + &:hover + border-color: #DDD + background: #F4F4F4 + text-decoration: none + + &.active + border-color: $secondary-color + background: rgba($secondary-color, 0.07) + +.messagepad__desc + font-size: rem($font-size-small) + color: #888 + max-height: 2em + margin-top: rem(5px) + overflow: hidden + text-overflow: ellipsis + word-wrap() diff --git a/packages/ui-default/components/messagepad/DialogueListItemComponent.jsx b/packages/ui-default/components/messagepad/DialogueListItemComponent.jsx new file mode 100644 index 000000000..b2777109c --- /dev/null +++ b/packages/ui-default/components/messagepad/DialogueListItemComponent.jsx @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default function DialogueListItemComponent(props) { + const { + userName, + summary, + faceUrl, + active, + onClick, + className, + ...rest + } = props; + const cn = classNames(className, 'messagepad__list-item media', { + active, + }); + return ( +
  • + +
    + avatar +
    +
    +

    {userName}

    +
    {summary}
    +
    +
    +
  • + ); +} + +DialogueListItemComponent.propTypes = { + userName: PropTypes.string.isRequired, + summary: PropTypes.string.isRequired, + faceUrl: PropTypes.string.isRequired, + active: PropTypes.bool, + onClick: PropTypes.func, + className: PropTypes.string, +}; diff --git a/packages/ui-default/components/messagepad/Message.page.styl b/packages/ui-default/components/messagepad/Message.page.styl new file mode 100644 index 000000000..fc443afe9 --- /dev/null +++ b/packages/ui-default/components/messagepad/Message.page.styl @@ -0,0 +1,64 @@ +$corner-size = 10px + +.messagepad__message + overflow: hidden + display: flex + margin-bottom: rem(30px) + + &:last-child + margin-bottom: 0 + + &.side--self + justify-content: flex-end + +.messagepad__message__body + font-size: rem($font-size-secondary) + line-height: 1.5 + padding: rem(15px) + margin-bottom: rem(8px) + position: relative + min-width: rem(100px) + white-space: pre-wrap + word-wrap() + + time + opacity: 0.6 + font-size: rem($font-size-small) + + &:after + position: absolute + width: 0 + height: 0 + content: '' + top: 0 + +.side--other + .messagepad__message__body + margin-left: rem($corner-size + 10px) + background: $primary-color + color: #FFF + order: 1 + + &:after + left: rem(-($corner-size)) + border-top: $corner-size solid $primary-color + border-right: 0 solid transparent + border-bottom: 0 solid transparent + border-left: $corner-size solid transparent + +.side--self + .messagepad__message__body + margin-right: rem($corner-size + 10px) + background: #F0F0F0 + color: #222 + order: -1 + + time + text-align: right + + &:after + right: rem(-($corner-size)) + border-top: $corner-size solid #F0F0F0 + border-right: $corner-size solid transparent + border-bottom: 0 solid transparent + border-left: 0 solid transparent diff --git a/packages/ui-default/components/messagepad/MessageComponent.jsx b/packages/ui-default/components/messagepad/MessageComponent.jsx new file mode 100644 index 000000000..de0c3ceb8 --- /dev/null +++ b/packages/ui-default/components/messagepad/MessageComponent.jsx @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default function MessageComponent(props) { + const { + faceUrl, + isSelf, + className, + children, + ...rest + } = props; + const cn = classNames(className, 'messagepad__message', { + 'side--self': isSelf, + 'side--other': !isSelf, + }); + return ( +
  • +
    + avatar +
    +
    + {children} +
    +
  • + ); +} + +MessageComponent.propTypes = { + isSelf: PropTypes.bool, + faceUrl: PropTypes.string.isRequired, + className: PropTypes.string, + children: PropTypes.node, +}; diff --git a/packages/ui-default/components/messagepad/MessagePad.page.styl b/packages/ui-default/components/messagepad/MessagePad.page.styl new file mode 100644 index 000000000..f6b9e787d --- /dev/null +++ b/packages/ui-default/components/messagepad/MessagePad.page.styl @@ -0,0 +1,89 @@ +$list-height = 500px +$sidebar-width = 250px +$input-height = 80px +$input-padding = 10px +$send-button-width = 50px + +#messagePad + min-height: rem($list-height) + position: relative + +.messagepad + position: relative + +.messagepad__sidebar + border-right: 1px solid #EEE + float: left + width: rem($sidebar-width) + +.messagepad__list + overflow: auto + height: rem($list-height) + +.messagepad__content + position: absolute + left: rem($sidebar-width) + right: 0 + top: 0 + bottom: rem($input-height) + overflow: auto + padding: rem(30px) + +.messagepad__input + display: none + position: absolute + left: rem($sidebar-width) + right: 0 + bottom: 0 + height: $input-height + border-top: 1px solid #EEE + + &.visible + display: block + + button + display: block + appearance: none + position: absolute + right: 0 + width: rem($send-button-width) + top: 0 + height: 100% + border: 0 + background: none + text-align: center + line-height: rem($input-height) + font-size: rem($font-size-icon) + transition: background-color .1s, color .1s, opacity .1s + transition-timing-function: linear + outline: 0 + + &:enabled + cursor: pointer + background-color: $primary-color + color: #FFF + + &:hover + background-color: lighten($primary-color, 10%) + + &:disabled + color: $text-1-color + opacity: 0.3 + +.messagepad__textarea-container + position: absolute + left: rem($input-padding) + right: rem($send-button-width + $input-padding) + top: rem($input-padding) + bottom: rem($input-padding) + + textarea + display: block + border: 0 + background: none + padding: 0 + width: 100% + height: 100% + font-size: rem($font-size-secondary) + resize: none + outline: 0 diff --git a/packages/ui-default/components/messagepad/MessagePadDialogueContentContainer.jsx b/packages/ui-default/components/messagepad/MessagePadDialogueContentContainer.jsx new file mode 100644 index 000000000..52ba452c1 --- /dev/null +++ b/packages/ui-default/components/messagepad/MessagePadDialogueContentContainer.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import _ from 'lodash'; +import { connect } from 'react-redux'; +import TimeAgo from 'timeago-react'; +import moment from 'moment'; +import 'jquery-scroll-lock'; +import 'jquery.easing'; + +import i18n from 'vj/utils/i18n'; +import { parse as parseMongoId } from 'vj/utils/mongoId'; +import Message from './MessageComponent'; + +const mapStateToProps = (state) => ({ + activeId: state.activeId, + item: state.activeId !== null + ? state.dialogues[state.activeId] + : null, +}); + +export default connect(mapStateToProps, null)(class MessagePadDialogueContentContainer extends React.PureComponent { + componentDidMount() { + $(this.refs.list).scrollLock({ strict: true }); + } + + componentDidUpdate(prevProps) { + const node = this.refs.list; + if (this.props.activeId !== prevProps.activeId) { + this.scrollToBottom = true; + this.scrollWithAnimation = false; + } else if (node.scrollTop + node.offsetHeight === node.scrollHeight) { + this.scrollToBottom = true; + this.scrollWithAnimation = true; + } else this.scrollToBottom = false; + + if (this.scrollToBottom) { + const targetScrollTop = node.scrollHeight - node.offsetHeight; + if (this.scrollWithAnimation) { + $(node).stop().animate({ scrollTop: targetScrollTop }, 200, 'easeOutCubic'); + } else { + node.scrollTop = targetScrollTop; + } + } + } + + renderInner() { + if (this.props.activeId === null) { + return []; + } + return _.map(this.props.item.messages, (reply) => ( + +
    {reply.content}
    + +
    + )); + } + + render() { + return ( +
      + {this.renderInner()} +
    + ); + } +}); diff --git a/packages/ui-default/components/messagepad/MessagePadDialogueListContainer.jsx b/packages/ui-default/components/messagepad/MessagePadDialogueListContainer.jsx new file mode 100644 index 000000000..8bd203b3c --- /dev/null +++ b/packages/ui-default/components/messagepad/MessagePadDialogueListContainer.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import _ from 'lodash'; +import { connect } from 'react-redux'; +import 'jquery-scroll-lock'; + +import ListItem from './DialogueListItemComponent'; + +const mapStateToProps = (state) => ({ + activeId: state.activeId, + dialogues: state.dialogues, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleClick(id) { + dispatch({ + type: 'DIALOGUES_SWITCH_TO', + payload: id, + }); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(class MessagePadDialogueListContainer extends React.PureComponent { + componentDidMount() { + $(this.refs.list).scrollLock({ strict: true }); + } + + render() { + const orderedDialogues = _.orderBy( + _.values(this.props.dialogues), + (dialogue) => (_.maxBy(dialogue.messages, '_id') + ? _.maxBy(dialogue.messages, '_id')._id + : Number.POSITIVE_INFINITY), + 'desc', + ); + return ( +
      + {_.map(orderedDialogues, (dialogue) => ( + this.props.handleClick(dialogue._id)} + /> + ))} +
    + ); + } +}); diff --git a/packages/ui-default/components/messagepad/MessagePadInputContainer.jsx b/packages/ui-default/components/messagepad/MessagePadInputContainer.jsx new file mode 100644 index 000000000..53e8fc58a --- /dev/null +++ b/packages/ui-default/components/messagepad/MessagePadInputContainer.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; +import Icon from 'vj/components/react/IconComponent'; + +import request from 'vj/utils/request'; + +const mapStateToProps = (state) => ({ + activeId: state.activeId, + isPosting: state.activeId !== null + ? state.isPosting[state.activeId] + : false, + inputValue: state.activeId !== null + ? state.inputs[state.activeId] + : '', +}); + +const mapDispatchToProps = (dispatch) => ({ + handleChange(id, value) { + if (id === null) return; + dispatch({ + type: 'DIALOGUES_INPUT_CHANGED', + payload: value, + meta: { + dialogueId: id, + }, + }); + }, + postSend(uid, value) { + const req = request.post('', { + operation: 'send', + uid, + content: value, + }); + dispatch({ + type: 'DIALOGUES_POST_SEND', + payload: req, + meta: { + dialogueId: uid, + }, + }); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(class MessagePadInputContainer extends React.PureComponent { + static contextTypes = { + store: PropTypes.object, + }; + + componentDidUpdate(prevProps) { + this.focusInput = ( + this.props.activeId !== prevProps.activeId + || prevProps.isPosting !== this.props.isPosting && this.props.isPosting === false + ); + if (this.focusInput) { + const { scrollX, scrollY } = window; + this.refs.input.focus(); + window.scrollTo(scrollX, scrollY); + } + } + + handleKeyDown(ev) { + if (ev.keyCode === 13 && (ev.ctrlKey || ev.metaKey)) { + this.submit(); + } + } + + submit() { + this.props.postSend( + this.props.activeId, + this.props.inputValue, + ); + } + + render() { + const cn = classNames('messagepad__input', { + visible: this.props.activeId !== null, + }); + return ( +
    +
    + +
    +
    + + +
    + + + +
    +
    + +
    +
    +
      +
    • +
      +
      + +
      +
      +
      +
      +
      +
    • +
    + + + +{% endif %} +{%- for doc in docs -%} + {% set udoc = udict[doc.owner] %} +
  • +
    +
    + +
    +
    +
    +
    + {{ user.render_inline(udoc, avatar=false) }} + @ {{ datetimeSpan(doc['_id'])|safe }} +
    +
    + {% if handler.user.hasPerm(reply_post_perm) %} + + {% endif %} + {% if (handler.user.hasPerm(comment_edit_self_perm) and doc.owner == handler.user._id) or handler.user.hasPerm(comment_edit_perm) %} + + {% endif %} + {% if (handler.user.hasPerm(comment_delete_self_perm) and doc.owner == handler.user._id) or handler.user.hasPerm(comment_delete_perm) %} + + {% endif %} +
    +
    +
    + {{ doc['content']|markdown|safe }} +
    +
    +
      + {%- for rdoc in doc['reply'] -%} + {% set udoc = udict[rdoc.owner] %} +
    • +
      +
      + +
      +
      +
      +
      + {{ user.render_inline(udoc, avatar=false) }} + @ {{ datetimeSpan(rdoc._id)|safe }} +
      +
      + {% if handler.user.hasPerm(reply_post_perm) %} + + {% endif %} + {% if doc.owner == handler.user._id or handler.user.hasPerm(reply_edit_perm) %} + + {% endif %} + {% if doc.owner == handler.user._id or handler.user.hasPerm(reply_delete_perm) %} + + {% endif %} +
      +
      +
      + {{ rdoc.content|markdown|safe }} +
      +
      +
      +
      +
    • + {%- endfor -%} +
    +
    +
    +
  • +{%- endfor -%} + +{% endmacro %} diff --git a/packages/ui-default/templates/components/comments_solution.html b/packages/ui-default/templates/components/comments_solution.html new file mode 100644 index 000000000..022d7dc9e --- /dev/null +++ b/packages/ui-default/templates/components/comments_solution.html @@ -0,0 +1,177 @@ +{% import "components/user.html" as user with context %} +{% macro reply(mode_create, udoc, doc = {}, rdoc = {}) %} + +{% endmacro %} + +{% macro render( + view, docs, udict, + comment_ref, + reply_ref, + comment_post_perm, + comment_edit_perm, + comment_edit_self_perm, + comment_delete_perm, + comment_delete_self_perm, + reply_post_perm, + reply_edit_perm, + reply_edit_self_perm, + reply_delete_perm, + reply_delete_self_perm, + comment_placeholder = 'Write Your Comment', + comment_post_op = 'reply', + comment_edit_op = 'edit_reply', + comment_post_text = 'Comment', + comment_edit_text = 'Update', + reply_post_op = 'tail_reply', + reply_post_text = 'Reply', + reply_edit_op = 'edit_tail_reply', + reply_edit_text = 'Update', + comment_delete_op = 'delete_reply', + reply_delete_op = 'delete_tail_reply' +) %} +
      +{% if handler.user.hasPerm(comment_post_perm) %} +
    • +
      +
      +
      +
      0
      +
      +
      +
      +
      +
      + {{ user.render_inline(handler.user) }} +
      +
      +
      +
      +
      + +
      +
      + + +
      + + +
      +
      +
      + +
      +
      +
        +
      • +
        +
        +
        +
        + {{ user.render_inline(handler.user, badge=false) }} +
        +
        +
        +
        +
        +
      • +
      +
      +
      +
    • +{% endif %} +{%- for doc in docs -%} + {% set udoc = udict[doc.owner] %} +
    • +
      +
      +
      + {% if handler.user.hasPerm(perm.PERM_VOTE_PROBLEM_SOLUTION) %} +
      {{ doc.vote }}
      +
      +
      + + + + +
      +
      + + + + +
      +
      + {% else %} +
      {{ doc.vote }}
      + {% endif %} +
      +
      +
      +
      +
      + {{ user.render_inline(udoc) }} + @ {{ datetimeSpan(doc._id)|safe }} +
      +
      + {% if handler.user.hasPerm(reply_post_perm) %} + + {% endif %} + {% if (handler.user.hasPerm(comment_edit_self_perm) and doc.owner == handler.user._id) or handler.user.hasPerm(comment_edit_perm) %} + + {% endif %} + {% if (handler.user.hasPerm(comment_delete_self_perm) and doc.owner == handler.user._id) or handler.user.hasPerm(comment_delete_perm) %} + + {% endif %} +
      +
      +
      + {{ doc['content']|markdown|safe }} +
      +
      +
        + {%- for rdoc in doc['reply'] -%} + {% set udoc = udict[rdoc['owner']] %} +
      • +
        +
        +
        +
        + {{ user.render_inline(udoc, badge=false) }} @ {{ datetimeSpan(rdoc['_id'])|safe }} +
        +
        + {% if handler.user.hasPerm(reply_post_perm) %} + + {% endif %} + {% if (handler.user.hasPerm(reply_edit_self_perm) and doc.owner == handler.user._id) or handler.user.hasPerm(reply_edit_perm) %} + + {% endif %} + {% if (handler.user.hasPerm(reply_delete_self_perm) and doc.owner == handler.user._id) or handler.user.hasPerm(reply_delete_perm) %} + + {% endif %} +
        +
        +
        + {{ rdoc['content']|markdown|safe }} +
        +
        +
        +
        +
      • + {%- endfor -%} +
      +
      +
      +
    • +{%- endfor -%} +
    +{% endmacro %} diff --git a/packages/ui-default/templates/components/contest.html b/packages/ui-default/templates/components/contest.html new file mode 100644 index 000000000..cd46bf87b --- /dev/null +++ b/packages/ui-default/templates/components/contest.html @@ -0,0 +1,8 @@ +{% import "components/user.html" as user with context %} +{% macro render_time(tdoc_time) %} +{{ datetimeSpan(tdoc_time, false, '%Y-%m-%d %H:%M')|safe }} +{% endmacro %} + +{% macro render_duration(tdoc) %} +{{ ((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) /1000 / 3600)|round(1) }} +{% endmacro %} diff --git a/packages/ui-default/templates/components/form.html b/packages/ui-default/templates/components/form.html new file mode 100644 index 000000000..9be28d33c --- /dev/null +++ b/packages/ui-default/templates/components/form.html @@ -0,0 +1,203 @@ +{% macro form_begin(args) %} +{% set label = args.label %} +{% set columns = args.columns %} +{% set last_column = args.last_column|default(true) %} +{% set required = args.required or false %} +{% set hotkeys = args.hotkeys or none %} +{% set row = args.row|default(true) %} +{% set no_label = args.no_label or false %} +{% set label_wrap = args.label_wrap|default(true) %} +{% if row %} +
    +{% endif %} +
    + {% if not no_label %} + + {% endif %} + {% endif %} +{% endmacro %} + +{% macro form_end(args) %} +{% set help_text = args.help_text or none %} +{% set row = args.row|default(true) %} +{% set no_label = args.no_label or false %} +{% set label_wrap = args.label_wrap|default(true) %} + {% if not no_label and label_wrap %} + + {% endif %} + {% if help_text %} +

    {{ _(help_text)|safe }}

    + {% endif %} +
    +{% if row %} +
    +{% endif %} +{% endmacro %} + +{% macro form_attr(form_class, args) %} +{% set disabled = args.disabled or false %} +{% set required = args.required or false %} +{% set autofocus = args.autofocus or false %} +{% set extra_style = args.extra_style or false %} +{% set date = args.date or false %} +{% set time = args.time or false %} + class="{{ form_class }}{% if extra_class %} {{ extra_class }}{% endif %}" + {% if disabled %}disabled{% endif %} + {% if required %}required{% endif %} + {% if autofocus %}autofocus data-autofocus{% endif %} + {% if date %}data-pick-date{% endif %} + {% if time %}data-pick-time{% endif %} + {% if extra_style %}style="{{extra_style}}"{% endif %} +{% endmacro %} + +{% macro container_attr(container_class, args) %} +{% set extra_class = args.extra_class or none %} + class="{{ container_class }}{% if extra_class %} {{ extra_class }}{% endif %}" +{% endmacro %} + +{% macro select(args) %} +
    + +
    +{% endmacro %} + +{% macro form_select(args) %} +{{ form_begin({columns:5}|assign(args)) }} +{{ select(args) }} +{{ form_end(args) }} +{% endmacro %} + +{% macro radio(args) %} +
    +{%- for k, v in options -%} + +{%- endfor -%} +
    +{% endmacro %} + +{% macro form_radio(args) %} +{{ form_begin({columns:5, label_wrap:false}|assign(args)) }} +{{ radio(args) }} +{{ form_end({label_warp:false}|assign(args)) }} +{% endmacro %} + +{% macro image_radio(args) %} +
    +{%- for k, v in options -%} + +{%- endfor -%} +
    +{% endmacro %} + +{% macro form_image_radio(args) %} +{{ form_begin({columns:8, label_warp:false}|assign(args)) }} +{{ image_radio(args) }} +{{ form_end({label_warp:false}|assign(args)) }} +{% endmacro %} + +{% macro text(args) %} +
    + +
    +{% endmacro %} + +{% macro form_text(args) %} +{{ form_begin({columns:5}|assign(args)) }} +{{ text(args) }} +{{ form_end(args) }} +{% endmacro %} + +{% macro textarea(args) %} +{% set markdown = args.markdown or false %} +
    + +
    +{% endmacro %} + +{% macro form_textarea(args) %} +{{ form_begin({columns:8}|assign(args)) }} +{{ textarea(args) }} +{{ form_end(args) }} +{% endmacro %} + +{% macro checkbox(args) %} +
    + +
    +{% endmacro %} + +{% macro form_checkbox(args) %} +{{ form_begin({columns:5, no_label:true}|assign(args)) }} +{{ checkbox(args) }} +{{ form_end({no_label:true}|assign(args)) }} +{% endmacro %} diff --git a/packages/ui-default/templates/components/home.html b/packages/ui-default/templates/components/home.html new file mode 100644 index 000000000..e0683b5f2 --- /dev/null +++ b/packages/ui-default/templates/components/home.html @@ -0,0 +1,15 @@ +{% import "components/sidemenu.html" as sidemenu with context %} +{% macro render_sidebar() %} +
    + +
    +{% endmacro %} diff --git a/packages/ui-default/templates/components/homework.html b/packages/ui-default/templates/components/homework.html new file mode 100644 index 000000000..253c5b02f --- /dev/null +++ b/packages/ui-default/templates/components/homework.html @@ -0,0 +1,8 @@ +{% import "components/user.html" as user with context %} +{% macro render_time(tdoc_time) %} +{{ datetimeSpan(tdoc_time, false, '%Y-%m-%d %H:%M')|safe }} +{% endmacro %} + +{% macro render_extension(tdoc) %} +{{ ((tdoc['endAt'].getTime() - tdoc['penaltySince'].getTime())/ 1000 / 3600)|round(1) }} +{% endmacro %} diff --git a/packages/ui-default/templates/components/md_hint.html b/packages/ui-default/templates/components/md_hint.html new file mode 100644 index 000000000..f1281fbf0 --- /dev/null +++ b/packages/ui-default/templates/components/md_hint.html @@ -0,0 +1,37 @@ +
    +
    +
    +
    {{ _('Inline codes') }}
    +
    `std::cout()`
    +
    {{ _('Code blocks') }}
    +
    ```c++
    +int main() {
    +return 0;
    +}
    +```
    +
    {{ _('Inline formulas') }}
    +
    $ E=mc^2 $
    +
    {{ _('Formula blocks') }}
    +
    $$ E=mc^2 $$
    +
    {{ _('Links') }}
    +
    [{{ _('Text') }}](https://...)
    +
    {{ _('Highlight') }}
    +
    =={{ _('Text') }}==
    +
    {{ _('Images') }}
    +
    ![{{ _('Description') }}](https://...)
    +
    {{ _('Images With Size') }}
    +
    ![{{ _('Description') }}](url = 100x100)
    +
    {{ _('Footnote') }}
    +
    Here is a reference[^1].
    +
    +[^1]: Here is the footnote.
    +
    {{ _('Videos') }}
    +
    @[{{ _('Description') }}](https://.../file.mp4)
    +
    +

    {{ _('Learn More') }}

    + +
    +
    diff --git a/packages/ui-default/templates/components/noscript_note.html b/packages/ui-default/templates/components/noscript_note.html new file mode 100644 index 000000000..8f4e1c3bb --- /dev/null +++ b/packages/ui-default/templates/components/noscript_note.html @@ -0,0 +1,15 @@ +{% macro render() %} +{% if not isIE(handler.request.headers['user-agent']) %} + +{% else %} +
    +
    +

    {{ _('This page needs JavaScript to work.') }}

    +
    +
    +{% endif %} +{% endmacro %} diff --git a/packages/ui-default/templates/components/nothing.html b/packages/ui-default/templates/components/nothing.html new file mode 100644 index 000000000..b4f3143e7 --- /dev/null +++ b/packages/ui-default/templates/components/nothing.html @@ -0,0 +1,6 @@ +{% macro render(text, compact=false) %} +
    +
    + {{ _(text) }} +
    +{% endmacro %} diff --git a/packages/ui-default/templates/components/paginator.html b/packages/ui-default/templates/components/paginator.html new file mode 100644 index 000000000..b228dd0bd --- /dev/null +++ b/packages/ui-default/templates/components/paginator.html @@ -0,0 +1,25 @@ +{% macro render(page, num_pages, add_qs='') %} +{% if num_pages > 0 %} + +{% endif %} +{% endmacro %} diff --git a/packages/ui-default/templates/components/problem.html b/packages/ui-default/templates/components/problem.html new file mode 100644 index 000000000..cd2543a0d --- /dev/null +++ b/packages/ui-default/templates/components/problem.html @@ -0,0 +1,37 @@ +{% macro render_problem_title(pdoc, tdoc=none, show_tags=true, show_invisible_flag=true, invalid=false) %} +{%- if not invalid %} + +{%- endif %} +{{ pdoc.pid if pdoc.pid else 'P'+pdoc.docId }} {{ pdoc.title }} +{%- if not invalid %} + +{%- endif %} +{%- if pdoc.hidden and show_invisible_flag %} + ({{ _('Hidden') }}) +{%- endif %} +{%- if show_tags %} + {{ render_problem_tags(pdoc) }} +{%- endif %} +{% endmacro %} + +{% macro render_problem_tags(pdoc, show_none_label=false) %} +{%- if pdoc.tag|length > 0 %} +
      + {%- for tag in pdoc['tag'] %} +
    • {{ tag }}
    • + {%- endfor %} +
    +{%- else %} + {%- if show_none_label %} + {{ _('(None)') }} + {%- endif %} +{%- endif %} +{% endmacro %} diff --git a/packages/ui-default/templates/components/record.html b/packages/ui-default/templates/components/record.html new file mode 100644 index 000000000..bdd156bab --- /dev/null +++ b/packages/ui-default/templates/components/record.html @@ -0,0 +1,15 @@ +{% macro render_status_td(rdoc, rid_key='_id') %} + + + {% if rdoc.status == STATUS.STATUS_JUDGING %} +
    +
    +
    + {% endif %} + +{% endmacro %} diff --git a/packages/ui-default/templates/components/sidemenu.html b/packages/ui-default/templates/components/sidemenu.html new file mode 100644 index 000000000..8a9ebcc64 --- /dev/null +++ b/packages/ui-default/templates/components/sidemenu.html @@ -0,0 +1,15 @@ +{% macro render_item(icon_name, page_name, args={}, label=none) %} +{% set target_url = url(page_name, args) if page_name else '#' %} + +{% endmacro %} diff --git a/packages/ui-default/templates/components/user.html b/packages/ui-default/templates/components/user.html new file mode 100644 index 000000000..7c4b676a6 --- /dev/null +++ b/packages/ui-default/templates/components/user.html @@ -0,0 +1,35 @@ +{% macro render_inline(udoc, avatar=true, badge=true, modbadge=true) %} +{% if udoc %} + +{% else %} + +{% endif %} +{% endmacro %} diff --git a/packages/ui-default/templates/contest_boardcast.html b/packages/ui-default/templates/contest_boardcast.html new file mode 100644 index 000000000..999f4429e --- /dev/null +++ b/packages/ui-default/templates/contest_boardcast.html @@ -0,0 +1,31 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    + {{ form.form_textarea({ + columns:null, + label:'Content', + name:'content', + value:'' + }) }} +
    + + + +
    +
    +
    +
    +
    +
    + {% include 'components/md_hint.html' %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/contest_detail.html b/packages/ui-default/templates/contest_detail.html new file mode 100644 index 000000000..6b40b5349 --- /dev/null +++ b/packages/ui-default/templates/contest_detail.html @@ -0,0 +1,79 @@ +{% extends "layout/basic.html" %} +{% import "components/contest.html" as contest with context %} +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} +{% block content %} +
    +
    +
    +
    + {{ tdoc['content']|markdown|safe }} + {% if model.contest.isOngoing(tdoc) %} +
    +

    {{ _('We will check code similarity after this contest.') }}

    +
    + {% endif %} +
    +
    + {% if model.contest.isOngoing(tdoc) or model.contest.isDone(tdoc) %} +
    +
    +

    {{ _('Problem') }}

    +
    + {% if attended or model.contest.isDone(tdoc) %} +
    + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + + {% endif %} + + + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + + {% endif %} + + + + + {%- for pid in tdoc.pids -%} + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + {% if psdict[pid] and psdict[pid].rid %} + {% set rdoc = rdict[psdict[pid].rid] %} + {% if handler.canShowRecord(tdoc) %} + {{ record.render_status_td(rdoc) }} + {% else %} + + {% endif %} + + {% else %} + + + {% endif %} + {% endif %} + + + {%- endfor -%} + +
    {{ _('Status') }}{{ _('Last Submit At') }}{{ _('Problem') }}
    {{ _('Submitted') }}{{ datetimeSpan(rdoc._id)|safe }}{{ _('No Submissions') }}- + #{{ loop.index }}: + {{ problem.render_problem_title(pdict[pid], tdoc=tdoc, show_invisible_flag=false, show_tags=false) }} +
    +
    + {% else %} + {{ nothing.render('Please attend contest to see the problems.') }} + {% endif %} +
    + {% endif %} +
    +
    + {% set owner_udoc = udict[tdoc.owner] %} + {% include "partials/contest_sidebar.html" %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/contest_edit.html b/packages/ui-default/templates/contest_edit.html new file mode 100644 index 000000000..4cff1e971 --- /dev/null +++ b/packages/ui-default/templates/contest_edit.html @@ -0,0 +1,97 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    +
    + {{ form.form_select({ + columns:4, + label:'Rule', + name:'rule', + options:rules, + value:tdoc['rule']|default(''), + row:false + }) }} + {{ form.form_text({ + columns:4, + label:'Title', + name:'title', + placeholder:_('title'), + value:tdoc.title|default(''), + autofocus:true, + row:false + }) }} + {{ form.form_checkbox({ + columns:4, + label:'Rated', + name:'rated', + placeholder:_('Is rated'), + value:tdoc.rated|default(true), + row:false + }) }} +
    + {{ form.form_text({ + columns:4, + label:'Begin Date', + name:'beginAtDate', + placeholder:'YYYY-mm-dd', + value:date_text, + date:true, + row:false + }) }} + {{ form.form_text({ + columns:4, + label:'Begin Time', + name:'beginAtTime', + placeholder:'HH:MM', + value:time_text, + time:true, + row:false + }) }} + {{ form.form_text({ + columns:4, + label:'Duration (hours)', + name:'duration', + value:duration|default('3'), + row:false + }) }} +
    + {{ form.form_text({ + columns:null, + label:'Problems', + name:'pids', + value:pids + }) }} + {{ form.form_textarea({ + columns:null, + label:'Content', + name:'content', + value:tdoc['content']|default(''), + markdown:true + }) }} +
    + + {% if page_name == 'contest_edit' %} + + {% else %} + + {% endif %} + +
    +
    +
    +
    +
    +
    + {% include 'components/md_hint.html' %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/contest_main.html b/packages/ui-default/templates/contest_main.html new file mode 100644 index 000000000..2dd5a2e60 --- /dev/null +++ b/packages/ui-default/templates/contest_main.html @@ -0,0 +1,132 @@ +{% extends "layout/basic.html" %} +{% import "components/contest.html" as contest with context %} +{% macro render_important_contest(view, title, tdoc) %} +
    +
    +
    +
    +
    +

    {{ _(title) }}

    +
    +

    {{ tdoc.title }}

    +
      +
    • + + {{ _('Rule') }}: {{ model.contest.RULES[tdoc['rule']].TEXT }} +
    • +
    • + + {{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }} +
    • +
    • + + {{ _('Duration') }}: {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} +
    • +
    • + + {{ _('Partic.') }}: {{ tdoc['attend']|default(0) }} +
    • +
    +
    +
    +
    +
    + {{ _('View Details') }} +
    + {% if tsdict[tdoc.docId].attend %} +

    {{ _('Attended') }}

    + {% else %} +

    {{ _('Not Attended') }}

    + {% endif %} +
    +
    +
    +
    +
    +{% endmacro %} +{% block content %} +
    +
    + {%- 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 %} + {%- endfor -%} +
    +
    +

    {% if not rule %}{{ _('All Contests') }}{% else %}{{ _('All {0} Contests').format(_(model.contest.RULES[rule].TEXT)) }}{% endif %}

    +
    +
    + {{ _('Show') }}: + + +
    +
    +
    +
    + {% if not tdocs.length %} + {{ nothing.render('There are no contests...') }} + {% else %} +
      + {%- for tdoc in tdocs -%} +
    1. +
      +
      +
      +
      {{ datetimeSpan(tdoc['beginAt'], false, '%d')|safe }}
      +
      {{ datetimeSpan(tdoc['beginAt'], false, '%Y-%m')|safe }}
      +
      +
      +
      +

      {{ tdoc.title }}

      +
        +
      • + {{ model.contest.RULES[tdoc.rule].TEXT }} +
      • +
      • + {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} +
      • +
      • + {{ tdoc['attend']|default(0) }} +
      • + {% if tsdict[tdoc.docId]['attend'] == 1 %} +
      • + {{ _('Attended') }} +
      • + {% endif %} +
      +
      +
      +
    2. + {%- endfor -%} +
    + {{ paginator.render(page, tpcount, add_qs=qs) }} + {% endif %} +
    +
    +
    +
    + {% if handler.user.hasPerm(perm.PERM_CREATE_CONTEST) %} +
    +
    +

    + {{ _('Create Contest') }} +

    +
    + +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/contest_scoreboard.html b/packages/ui-default/templates/contest_scoreboard.html new file mode 100644 index 000000000..1e3890cef --- /dev/null +++ b/packages/ui-default/templates/contest_scoreboard.html @@ -0,0 +1,55 @@ +{% extends "layout/basic.html" %} +{% set type = 'homework' if tdoc.docType == 60 else 'contest' %} +{% block content %} +
    +
    + +
    + + + {%- for column in rows[0] -%} + + {%- endfor -%} + + + + {%- for column in rows[0] -%} + + {%- endfor -%} + + + + {%- for row in rows -%}{% if loop.index0>0 %} + + {%- for column in row -%} + + {%- endfor -%} + + {% endif %}{%- endfor -%} + +
    + {% if column.type == 'problem_detail' %} + {{ column.value }} + {% else %} + {{ column.value }} + {% endif %} +
    + {% if column.type == 'user' %} + {{ user.render_inline(udict[column.raw], badge=false) }} + {% elif column.type == 'record' and column.raw %} + {{ column.value }} + {% else %} + {{ column.value }} + {% endif %} +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/contest_scoreboard_download_html.html b/packages/ui-default/templates/contest_scoreboard_download_html.html new file mode 100644 index 000000000..b6a61565e --- /dev/null +++ b/packages/ui-default/templates/contest_scoreboard_download_html.html @@ -0,0 +1,48 @@ + + + + + + {%- for column in rows[0] -%} + + {%- endfor -%} + + + + {%- for row in rows -%}{% if loop.index0 %} + + {%- for column in row -%} + + {%- endfor -%} + + {% endif %}{%- endfor -%} + +
    + {{ column['value'] }} +
    + {{ column['value'] }} +
    diff --git a/packages/ui-default/templates/discussion_create.html b/packages/ui-default/templates/discussion_create.html new file mode 100644 index 000000000..6362dd208 --- /dev/null +++ b/packages/ui-default/templates/discussion_create.html @@ -0,0 +1,29 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    {{ _('Create Discussion in {0}').format(vnode.title) }}

    +
    +
    +
    + {% include "partials/discussion_edit_form.html" %} +
    + + + +
    +
    +
    +
    +
    +
    + {% include 'components/md_hint.html' %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/discussion_detail.html b/packages/ui-default/templates/discussion_detail.html new file mode 100644 index 000000000..d04adea24 --- /dev/null +++ b/packages/ui-default/templates/discussion_detail.html @@ -0,0 +1,130 @@ +{% extends "layout/basic.html" %} +{% import "components/comments_discussion.html" as comments with context %} +{% block content %} +
    +
    +
    +
    + +
    + {{ ddoc['content']|markdown|safe }} +
    +
    + +
    +
    +
    +

    {{ _('{0} comments').format(drcount) }}

    +
    +
    + {{ comments.render( + view = 'discussion', + docs = drdocs, + udict = udict, + comment_ref = 'drid', + reply_ref = 'drrid', + comment_post_op = 'reply', + reply_post_op = 'tail_reply', + comment_edit_op = 'edit_reply', + comment_delete_op = 'delete_reply', + reply_edit_op = 'edit_tail_reply', + reply_delete_op = 'delete_tail_reply', + comment_post_perm = perm.PERM_REPLY_DISCUSSION, + comment_edit_perm = perm.PERM_NEVER, + comment_delete_perm = perm.PERM_DELETE_DISCUSSION_REPLY_SELF_DISCUSSION if ddoc.owner == handler.user._id else perm.PERM_DELETE_DISCUSSION_REPLY, + comment_edit_self_perm = perm.PERM_EDIT_DISCUSSION_REPLY_SELF, + comment_delete_self_perm = perm.PERM_DELETE_DISCUSSION_REPLY_SELF, + reply_post_perm = perm.PERM_REPLY_DISCUSSION, + reply_edit_perm = perm.PERM_NEVER, + reply_delete_perm = perm.PERM_DELETE_DISCUSSION_REPLY_SELF_DISCUSSION if ddoc.owner == handler.user._id else perm.PERM_DELETE_DISCUSSION_REPLY, + reply_edit_self_perm = perm.PERM_EDIT_DISCUSSION_REPLY_SELF, + reply_delete_self_perm = perm.PERM_DELETE_DISCUSSION_REPLY_SELF + ) }} + {{ paginator.render(page, pcount) }} + {% if drcount == 0 %} + {{ nothing.render('No comments so far...') }} + {% endif %} +
    +
    +
    +
    + {% set udoc = udict[ddoc.owner] %} +
    + +
    + +
    +
    + +
    +
    + {% if vnode.type == model.document.TYPE_PROBLEM %} + {% set pdoc = vnode %} + {% set owner_udoc = udict[vnode.owner] %} + {% include "partials/problem_sidebar.html" %} + {% endif %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/discussion_edit.html b/packages/ui-default/templates/discussion_edit.html new file mode 100644 index 000000000..2b89d5800 --- /dev/null +++ b/packages/ui-default/templates/discussion_edit.html @@ -0,0 +1,31 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    + {% include "partials/discussion_edit_form.html" %} +
    + + + {% if ddoc.owner == handler.user._id or handler.user.hasPerm(perm.PERM_DELETE_DISCUSSION) %} + + {% endif %} + +
    +
    +
    +
    +
    +
    + {% include 'components/md_hint.html' %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/discussion_main_or_node.html b/packages/ui-default/templates/discussion_main_or_node.html new file mode 100644 index 000000000..fa89fdb61 --- /dev/null +++ b/packages/ui-default/templates/discussion_main_or_node.html @@ -0,0 +1,44 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    + {% include "partials/discussion_list.html" %} +
    +
    +
    + {% if vnode.type == model.document.TYPE_PROBLEM %} + {% set pdoc = vnode %} + {% set owner_udoc = udict[vnode.owner] %} + {% include "partials/problem_sidebar.html" %} + {% elif vnode.type == model.document.TYPE_CONTEST %} + {% set tdoc = vnode %} + {% set owner_udoc = udict[vnode.owner] %} + {% include "partials/contest_sidebar.html" %} + {% elif vnode.type == model.document.TYPE_DISCUSSION_NODE %} +
    + {% if vnode.pic %}
    {% endif %} +
    +

    {{ vnode['title'] or _('Create Discussion') }}

    +
    +
    + {% if vnode %} + {% if handler.user.hasPerm(perm.PERM_CREATE_DISCUSSION) %} +

    {{ _('Create a Discussion') }}

    + {% else %} + {% if not handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +

    {{ _('Login to Create a Discussion') }}

    + {% else %} +

    {{ _('No Permission to Create a Discussion') }}

    + {% endif %} + {% endif %} + {% else %} +

    {{ _('Select a node to create discussion.') }}

    + {% endif %} +
    +
    + {% endif %} + {% include 'partials/discussion_nodes_widget.html' %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/domain_base.html b/packages/ui-default/templates/domain_base.html new file mode 100644 index 000000000..537d4721c --- /dev/null +++ b/packages/ui-default/templates/domain_base.html @@ -0,0 +1,34 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    + {% block domain_content %}{% endblock %} +
    +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/domain_create.html b/packages/ui-default/templates/domain_create.html new file mode 100644 index 000000000..ea957b6b4 --- /dev/null +++ b/packages/ui-default/templates/domain_create.html @@ -0,0 +1,56 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    {{ _('Create Domain') }}

    +
    +
    +
    +

    {{ _('A domain ID cannot be changed after creation. It is supposed to be:') }}

    +
      +
    • {{ _('Unique') }}
    • +
    • {{ _('At least 4 characters') }}
    • +
    • {{ _('Not wrapped with space') }}
    • +
    • {{ _('Not started with a number') }}
    • +
    • {{ _('Only A-Z, a-z, 0-9 and _ are accepted') }}
    • +
    +
    +
    + {{ form.form_text({ + label:'ID', + name:'id', + autofocus:true, + required:true + }) }} + {{ form.form_text({ + label:'Name', + name:'name', + required:true + }) }} + {{ form.form_textarea({ + label:'Bulletin', + name:'bulletin', + markdown:true, + required:true + }) }} + {{ form.form_text({ + label:'Gravatar', + name:'gravatar' + }) }} +
    + + + +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/domain_dashboard.html b/packages/ui-default/templates/domain_dashboard.html new file mode 100644 index 000000000..f4c14c80c --- /dev/null +++ b/packages/ui-default/templates/domain_dashboard.html @@ -0,0 +1,28 @@ +{% import "components/user.html" as user %} +{% extends "domain_base.html" %} +{% block domain_content %} +{% if domain.bulletin %} +
    +
    + {{ domain.bulletin|markdown|safe }} +
    +
    +{% endif %} +
    +
    +

    {{ _('Information') }}

    +
    +
    + + +
    +
    +
    +
    +
    +
    {{ _('Gravatar') }}
    +
    {{ _('Name') }}
    {{ domain.name }}
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/domain_edit.html b/packages/ui-default/templates/domain_edit.html new file mode 100644 index 000000000..b312beb5f --- /dev/null +++ b/packages/ui-default/templates/domain_edit.html @@ -0,0 +1,6 @@ +{% extends "domain_base.html" %} +{% block domain_content %} +
    + {% include "partials/setting.html" %} +
    +{% endblock %} diff --git a/packages/ui-default/templates/domain_join.html b/packages/ui-default/templates/domain_join.html new file mode 100644 index 000000000..01b33833f --- /dev/null +++ b/packages/ui-default/templates/domain_join.html @@ -0,0 +1,34 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    + {{ _('Join {0}').format(handler.domain['name']) }} +

    +
    +
    +

    {{ _('By clicking the button, you will become a member of the domain {0}.').format(handler.domain['name']) }}

    +
    +
    +
    + + {% if joinSettings['method'] == model.domain.JOIN_METHOD_CODE %} + {{ form.form_text({ + columns:None, + label:'Invitation Code', + name:'code', + required:true, + help_text:'You need to enter the invitation code to join the domain.', + value:code, + autofocus:true + }) }} + {% endif %} + +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/domain_join_applications.html b/packages/ui-default/templates/domain_join_applications.html new file mode 100644 index 000000000..3724562cd --- /dev/null +++ b/packages/ui-default/templates/domain_join_applications.html @@ -0,0 +1,62 @@ +{% extends "domain_base.html" %} +{% block domain_content %} +{% if joinSettings %} +
    +
    +

    {{ _('Information') }}

    +
    +
    +

    {{ _('User can join this domain by visiting the following URL') }}:

    +
    {{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}
    + {% if joinSettings['method'] == model.domain.JOIN_METHOD_CODE %} +

    {{ _('Or, with automatically filled invitation code') }}:

    +
    {{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}&code={{ joinSettings.code|urlencode }}
    + {% endif %} + {% if not joinSettings['expire'] %} +
    +
    + {{ (_('The link will be expired at {0}').format(datetimeSpan(joinSettings.expire, relative=false)))|safe }} +
    + {% endif %} +
    +
    +{% endif %} +
    +
    +

    {{ _('Settings') }}

    +
    +
    +
    + {{ form.form_select({ + options:model.domain.JOIN_METHOD_RANGE, + label:'Method', + name:'method', + value:joinSettings['method']|default(model.domain.JOIN_METHOD_NONE) + }) }} + {{ form.form_select({ + options:rolesWithText, + label:'Role Assignment', + name:'role', + help_text:'The role to assign when user joining the domain.', + value:joinSettings['role']|default('') + }) }} + {{ form.form_select({ + options:expirations, + label:'Expire', + name:'expire', + help_text:'User will no longer be allowed to join the domain after expiration.' + }) }} + {{ form.form_text({ + label:'Invitation Code', + name:'invitationCode', + help_text:'The invitation code to enter to successfully join the domain. You can only use letters and numbers in the code and it should not be longer than 64 characters.', + value:joinSettings['code']|default('') + }) }} + + +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/domain_permission.html b/packages/ui-default/templates/domain_permission.html new file mode 100644 index 000000000..b89cff154 --- /dev/null +++ b/packages/ui-default/templates/domain_permission.html @@ -0,0 +1,67 @@ +{% extends "domain_base.html" %} +{% block domain_content %} +
    +
    +
    +

    {{ _('{0}: Permissions').format(domain.name) }}

    +
    +
    + + + + {%- for role in roles -%} + + {%- endfor -%} + + + + + {%- for role in roles -%} + + {%- endfor -%} + + + {%- for family, perms in PERMS_BY_FAMILY -%} + + + {%- for perm in perms -%} + + + {%- for role in roles -%} + + {%- endfor -%} + + {%- endfor -%} + + {%- endfor -%} + + + + + {%- for role in roles -%} + + {%- endfor -%} + + +
    {{ _('Permissions') }}{{ role._id }}
    {{ _(family) }}
    {{ _(perm.desc) }} + +
    placeholder
    placeholder + +
    +
    +
    + + + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/domain_role.html b/packages/ui-default/templates/domain_role.html new file mode 100644 index 000000000..28fedbaf3 --- /dev/null +++ b/packages/ui-default/templates/domain_role.html @@ -0,0 +1,72 @@ +{% extends "domain_base.html" %} +{% block domain_content %} + +
    +
    +

    {{ _('{0}: Roles').format(domain.name) }}

    +
    + +
    +
    + {{ noscript_note.render() }} +
    + + + + + + + + + + + + + + + + {%- for role in roles -%} + {% set isBuiltin = role._id in ['default', 'guest', 'admin'] %} + + + + + + {%- endfor -%} + +
    + + {{ _('Role') }}{{ _('Users') }}
    + + + {{ _('Built-in') if isBuiltin else _('User-defined role') }}: {{ role._id }} + + {{ role.count|default('--') }} +
    +
    +
    + +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/domain_user.html b/packages/ui-default/templates/domain_user.html new file mode 100644 index 000000000..020d01eb1 --- /dev/null +++ b/packages/ui-default/templates/domain_user.html @@ -0,0 +1,112 @@ +{% extends "domain_base.html" %} +{% block domain_content %} + + +
    +
    +

    {{ _('{0}: Users').format(domain.name) }}

    +
    + +
    +
    + {{ noscript_note.render() }} +
    + + + + + + + + + + + + + + + + + {%- for role in roles -%} + {%- for rudoc in rudocs[role._id] -%} + {% set is_disabled=(rudoc._id == handler.user._id) %} + + + + + + + {%- endfor -%} + {%- endfor -%} + +
    + + {{ _('User ID') }}{{ _('Username') }}{{ _('Role') }}
    + + + {{ rudoc._id }} + + {{ user.render_inline(udict[rudoc._id], badge=false) }} + + {{ form.select({ + options:rolesSelect, + name:'role', + value:rudoc.role, + extra_class:'compact', + disabled:is_disabled + }) }} +
    +
    +
    + + +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/error.html b/packages/ui-default/templates/error.html new file mode 100644 index 000000000..a86563e76 --- /dev/null +++ b/packages/ui-default/templates/error.html @@ -0,0 +1,22 @@ +{% set no_path_section = true %} +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    +

    {{ _('Oops!') }}

    +

    {{ _(error.message).formatFromArray(error.params) }}

    +

    {{ _('Technical Information') }}:

    +

    {{ _('Type') }}: {{ error.code }}

    +

    {{ _('Arguments') }}: +

      + {%- for param in error.params -%} +
    1. {{ param }}
    2. + {%- endfor -%} +
    +

    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/extra.css b/packages/ui-default/templates/extra.css new file mode 100644 index 000000000..051c1230a --- /dev/null +++ b/packages/ui-default/templates/extra.css @@ -0,0 +1,46 @@ +/* {% if header_logo %} */ +.header__logo { + background: url({{header_logo}}) +} +/* {% endif %} */ + +/* {% if header_background %} */ +.header { + background: url({{header_background}}) +} +/* {% endif %} */ + +/* {% if nav_logo_light %} */ +.nav__logo, .header--mobile__domain.is--system { + background-image: url({{nav_logo_light}}) +} +/* {% endif %} */ + +/* {% if nav_logo_dark %} */ +.nav.floating .nav__logo, .nojs .nav .nav__logo { + background-image: url({{nav_logo_dark}}) !important +} +/* {% endif %} */ + +@media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (min-resolution: 1.5dppx), only screen and (min-resolution: 144dpi) { + /* {% if header_2x %} */ + .header__logo { + background-image: url({{header_2x}}) + } + /* {% endif %} */ + /* {% if header_background_2x %} */ + .header { + background-image: url({{header_background_2x}}) + } + /* {% endif %} */ + /* {% if nav_logo_light_2x %} */ + .nav__logo, .header--mobile__domain.is--system { + background-image: url({{nav_logo_light_2x}}) !important + } + /* {% endif %} */ + /* {% if nav_logo_dark_2x %} */ + .nav.floating .nav__logo, .nojs .nav .nav__logo, .header--mobile__domain.is--system { + background-image: url({{nav_logo_dark_2x}}) !important + } + /* {% endif %} */ +} \ No newline at end of file diff --git a/packages/ui-default/templates/fs_upload.html b/packages/ui-default/templates/fs_upload.html new file mode 100644 index 000000000..398962734 --- /dev/null +++ b/packages/ui-default/templates/fs_upload.html @@ -0,0 +1,21 @@ +{% extends 'layout/simple.html' %} +{% block body %} +{% if fdoc %} +

    {{ _('Uploaded file info') }}:

    +

    {{ _('ID') }}: {{ fdoc['_id'] }}

    +

    {{ _('Userfile doc_id') }}: {{ ufid }}

    +

    {{ _('MD5') }}: {{ fdoc['md5'] }}

    +

    {{ _('URL') }}: {{ model.file.url(fdoc) }}

    +{% endif %} +
    + +

    +{{ _('New file') }}: + + + +

    +

    {{ _('Note: file size should be less than or equal to {0}, and file type should be in {1}. Empty files are not allowed.').format(size(FILE_MAX_LENGTH), ALLOWED_MIMETYPE_PREFIX) }}

    +

    {{ _('Usage: {0}, Quota: {1}').format(size(usage), size(quota)) }}

    +
    +{% endblock %} diff --git a/packages/ui-default/templates/home_domain.html b/packages/ui-default/templates/home_domain.html new file mode 100644 index 000000000..208c9f10e --- /dev/null +++ b/packages/ui-default/templates/home_domain.html @@ -0,0 +1,53 @@ +{% extends "layout/home_base.html" %} +{% block home_content %} +
    +
    +

    {{ _('My Domains') }}

    +
    + {% if handler.user.hasPriv(model.builtin.PRIV.PRIV_CREATE_DOMAIN) %} + {{ _('Create Domain') }} + {% endif %} + {{ _("What's domain?") }} +
    +
    +
    + + + + + + + + + + + + + + + + + {%- for ddoc in ddocs -%} + + + + + + + {%- endfor -%} + +
     {{ _('Name') }} ({{ _('ID') }}){{ _('My Role') }}{{ _('Action') }}
    + + + {{ ddoc['name'] }} ({{ ddoc['_id'] }}) + + {{ dudict[ddoc._id].role|default('default') }} + + {{ _('Visit') }} + {% if canManage[ddoc['_id']] %} + · {{ _('Manage') }} + {% endif %} +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/home_messages.html b/packages/ui-default/templates/home_messages.html new file mode 100644 index 000000000..6c96dd2ea --- /dev/null +++ b/packages/ui-default/templates/home_messages.html @@ -0,0 +1,24 @@ +{% extends "layout/home_base.html" %} +{% block home_content %} + +
    +
    + {{ noscript_note.render() }} +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/home_security.html b/packages/ui-default/templates/home_security.html new file mode 100644 index 000000000..82415f4ca --- /dev/null +++ b/packages/ui-default/templates/home_security.html @@ -0,0 +1,147 @@ +{% extends "layout/home_base.html" %} +{% block home_content %} +
    +
    + {% if handler.user.mail.endsWith('.local') %} +
    +
    +
    +
    {{ _("You haven't set an email.")}}
    +
    +
    +
    + {% endif %} +
    +
    +
    +

    {{ _('Change Email') }}

    +
    +
    +
    + {{ form.form_text({ + type:'password', + label:'Current Password', + columns:10, + name:'password', + required:true + }) }} + {{ form.form_text({ + type:'text', + label:'Current Email', + columns:10, + name:'currentEmail', + value:handler.user.mail, + disabled:true + }) }} + {{ form.form_text({ + type:'text', + label:'New Email', + columns:10, + name:'mail', + required:true + }) }} +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +

    {{ _('Change Password') }}

    +
    +
    +
    + {{ form.form_text({ + type:'password', + label:'Current Password', + columns:10, + name:'current', + required:true + }) }} + {{ form.form_text({ + type:'password', + label:'New Password', + columns:10, + name:'password', + required:true + }) }} + {{ form.form_text({ + type:'password', + label:'Repeat Password', + columns:10, + name:'verifyPassword', + required:true + }) }} +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    {{ _('Active Sessions') }}

    +
    +
    +
      + {%- for session in sessions -%} +
    • +
      +
      + +
      +
      +

      {{ _('Last Update At') }}: {{ datetimeSpan(session.updateAt)|safe }}

      + {% if session.createHost %} +

      {{ _('Hostname') }}: {{ session.createHost }}

      + {% endif %} +

      {{ _('Location') }}: {{ _(session.updateGeoip.display) }} ({{ session.updateIp }})

      +

      {{ _('Operating System') }}: {{ session.updateUa.os }}

      +

      {{ _('Browser') }}: {{ session.updateUa.name }} {{ session.updateUa.version }}

      + {% if session.isCurrent %} +

      {{ _('This is the current session') }}

      + {% endif %} +
      + {% if not session.isCurrent %} +
      +
      + + + + +
      +
      + {% endif %} +
      +
    • + {%- endfor -%} +
    +
    +
    +
    + + + +
    + {% if geoipProvider %} +
    +

    IP geo-location data is provided by {{ geoipProvider|safe }}.

    +
    + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/home_settings.html b/packages/ui-default/templates/home_settings.html new file mode 100644 index 000000000..7cffae76f --- /dev/null +++ b/packages/ui-default/templates/home_settings.html @@ -0,0 +1,6 @@ +{% extends "layout/home_base.html" %} +{% block home_content %} +
    + {% include "partials/setting.html" %} +
    +{% endblock %} diff --git a/packages/ui-default/templates/homework_detail.html b/packages/ui-default/templates/homework_detail.html new file mode 100644 index 000000000..8021b75d1 --- /dev/null +++ b/packages/ui-default/templates/homework_detail.html @@ -0,0 +1,77 @@ +{% extends "layout/basic.html" %} +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} +{% block content %} +
    +
    +
    +
    +

    {{ _('Homework Introduction') }}

    +
    +
    + {{ tdoc['content']|markdown|safe }} +
    +
    +
    +
    +

    {{ _('Problem') }}

    +
    + {% if model.contest.isNotStarted(tdoc) %} + {{ nothing.render('This homework is not open and you cannot view problems.') }} + {% else %} + {% if attended or model.contest.isDone(tdoc) %} +
    + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + + {% endif %} + + + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + + {% endif %} + + + + + {%- for pid in tdoc.pids -%} + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + {% if psdict[pid] and psdict[pid]['rid'] %} + {% set rdoc = rdict[psdict[pid]['rid']] %} + {% if handler.canShowRecord(tdoc) %} + {{ record.render_status_td(rdoc) }} + {% else %} + + {% endif %} + + {% else %} + + + {% endif %} + {% endif %} + + + {%- endfor -%} + +
    {{ _('Status') }}{{ _('Last Submit At') }}{{ _('Problem') }}
    {{ _('Submitted') }}{{ datetimeSpan(rdoc['_id'])|safe }}{{ _('No Submissions') }}- + #{{ loop.index }}: + {{ problem.render_problem_title(pdict[pid], tdoc=tdoc, show_invisible_flag=false, show_tags=false) }} +
    +
    + {% else %} + {{ nothing.render('Please claim the assignment to see the problems.') }} + {% endif %}{# attended or handler.is_done(tdoc) #} + {% endif %}{# handler.is_not_started(tdoc) #} +
    +
    +
    + {% include "partials/homework_sidebar.html" %} +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/homework_edit.html b/packages/ui-default/templates/homework_edit.html new file mode 100644 index 000000000..cf7f9852b --- /dev/null +++ b/packages/ui-default/templates/homework_edit.html @@ -0,0 +1,105 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    + {{ form.form_text({ + columns:8, + label:'Title', + name:'title', + placeholder:_('title'), + value:tdoc['title']|default(''), + autofocus:true + }) }} +
    + {{ form.form_text({ + columns:4, + label:'Begin Date', + name:'beginAtDate', + placeholder:'YYYY-mm-dd', + value:dateBeginText, + date:true, + row:false + }) }} + {{ form.form_text({ + columns:4, + last_column:true, + label:'Begin Time', + name:'beginAtTime', + placeholder:'HH:MM', + value:timeBeginText, + time:true, + row:false + }) }} +
    + {{ form.form_text({ + columns:4, + label:'End Date', + name:'penaltySinceDate', + placeholder:'YYYY-mm-dd', + value:datePenaltyText, + date:true, + row:false + }) }} + {{ form.form_text({ + columns:4, + last_column:true, + label:'End Time', + name:'penaltySinceTime', + placeholder:'HH:MM', + value:timePenaltyText, + time:true, + row:false + }) }} +
    + {{ form.form_text({ + columns:4, + label:'Extension (days)', + name:'extensionDays', + value:extension_days + }) }} +
    + +
    + {{ form.form_text({ + columns:null, + label:'Problems', + name:'pids', + value:pids + }) }} + {{ form.form_textarea({ + columns:null, + label:'Content', + name:'content', + value:tdoc['content']|default(''), + markdown:true + }) }} +
    + + {% if page_name == 'homework_edit' %} + + {% else %} + + {% endif %} + +
    +
    +
    +
    +
    +
    + {% include 'components/md_hint.html' %} +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/homework_main.html b/packages/ui-default/templates/homework_main.html new file mode 100644 index 000000000..80825d5dd --- /dev/null +++ b/packages/ui-default/templates/homework_main.html @@ -0,0 +1,64 @@ +{% extends "layout/basic.html" %} +{% block content %} +{{ set(UiContext, 'docs', calendar) }} +
    +
    +
    +
    +

    {{ _('All Homeworks') }}

    +
    + + {{ _('View') }}: + + + {% if handler.user.hasPerm(perm.PERM_CREATE_HOMEWORK) %} + {{ _('Create Homework') }} + {% endif %} +
    +
    +
    + {% if not tdocs.length %} + {{ nothing.render('There is no homework so far ╰(*°▽°*)╯') }} + {% else %} +
      + {%- for tdoc in tdocs -%} +
    1. +
      +
      +
      +
      {{ datetimeSpan(tdoc['penaltySince'], false, '%d')|safe }}
      +
      {{ datetimeSpan(tdoc['penaltySince'], false, '%Y-%m')|safe }}
      +
      +
      +
      +

      {{ tdoc.title }}

      +
        +
      • + {{ _('Status') }}: {{ _(model.contest.statusText(tdoc)) }} +
      • +
      • + {{ _('Open Since') }}: {{ datetimeSpan(tdoc['beginAt'])|safe }} +
      • +
      • + {% if model.contest.isExtended(tdoc) or model.contest.isDone(tdoc) %} + {{ _('Hard Deadline') }}: {{ datetimeSpan(tdoc['endAt'])|safe }} + {% else %} + {{ _('Deadline') }}: {{ datetimeSpan(tdoc['penaltySince'])|safe }} + {% endif %} +
      • +
      +
      +
      +
    2. + {%- endfor -%} +
    +
    + {% endif %} +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/layout/basic.html b/packages/ui-default/templates/layout/basic.html new file mode 100644 index 000000000..ef8bf6116 --- /dev/null +++ b/packages/ui-default/templates/layout/basic.html @@ -0,0 +1,19 @@ +{% set layout_name = "basic" %} +{% extends "layout/html5.html" %} +{% block body %} + +{% include "partials/nav.html" %} + +
    +
    + {% include "partials/header_mobile.html" %} + {% include "partials/header.html" %} +
    + {% block content %}{% endblock %} +
    + {% include "partials/footer.html" %} +
    + +{% include "partials/login_dialog.html" %} + +{% endblock %} diff --git a/packages/ui-default/templates/layout/home_base.html b/packages/ui-default/templates/layout/home_base.html new file mode 100644 index 000000000..b76cfae0f --- /dev/null +++ b/packages/ui-default/templates/layout/home_base.html @@ -0,0 +1,13 @@ +{% import "components/home.html" as home with context %} +{% extends "layout/basic.html" %} +{% block content %} +
    +
    + {% block home_content %}{% endblock %} +
    +
    + {{ home.render_sidebar() }} + {% block home_sidebar %}{% endblock %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/layout/html5.html b/packages/ui-default/templates/layout/html5.html new file mode 100644 index 000000000..682fa80e8 --- /dev/null +++ b/packages/ui-default/templates/layout/html5.html @@ -0,0 +1,52 @@ +{% import "components/form.html" as form with context %} +{% import "components/sidemenu.html" as sidemenu with context %} +{% import "components/paginator.html" as paginator with context %} +{% import "components/nothing.html" as nothing with context %} +{% import "components/noscript_note.html" as noscript_note with context %} +{% import "components/user.html" as user with context %} + + + + + + + + + + + + + + + + + {% block ogp %} + + + + + {% endblock %} + + + {{ handler.renderTitle(page_name) }} + {% if not isIE(handler.request.headers['user-agent']) %} + + {% endif %} + + + {{ handler.request.headers.hmr|default('')|safe }} + {% block body %}{% endblock %} + {% if not isIE(handler.request.headers['user-agent']) %} + + + + + {% endif %} + + diff --git a/packages/ui-default/templates/layout/immersive.html b/packages/ui-default/templates/layout/immersive.html new file mode 100644 index 000000000..7d7aa702d --- /dev/null +++ b/packages/ui-default/templates/layout/immersive.html @@ -0,0 +1,22 @@ +{% set layout_name = "immersive" %} +{% set no_path_section = true %} +{% extends "layout/html5.html" %} +{% block body %} + +{% include "partials/nav.html" %} + +
    +
    + {% include "partials/header_mobile.html" %} + +
    + {% block content %}{% endblock %} +
    + +{% set show_topics = false %} +{% include "partials/footer.html" %} +
    + +{% include "partials/login_dialog.html" %} + +{% endblock %} diff --git a/packages/ui-default/templates/layout/mail.html b/packages/ui-default/templates/layout/mail.html new file mode 100644 index 000000000..a8e6a09ee --- /dev/null +++ b/packages/ui-default/templates/layout/mail.html @@ -0,0 +1,30 @@ +{% set layout_name = "mail" %} + +
    +
    +
    {% block title %}{% endblock %} - Hydro
    +
    +{% block content %}{% endblock %} +
    + +
    +
    + diff --git a/packages/ui-default/templates/layout/simple.html b/packages/ui-default/templates/layout/simple.html new file mode 100644 index 000000000..a9ac9511b --- /dev/null +++ b/packages/ui-default/templates/layout/simple.html @@ -0,0 +1,4 @@ +{% set layout_name = "basic" %} +{% extends "layout/html5.html" %} +{% block body %} +{% endblock %} diff --git a/packages/ui-default/templates/layout/wiki_base.html b/packages/ui-default/templates/layout/wiki_base.html new file mode 100644 index 000000000..910590f39 --- /dev/null +++ b/packages/ui-default/templates/layout/wiki_base.html @@ -0,0 +1,16 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    + {% block wiki_content %}{% endblock %} +
    +
    +
    + +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/main.html b/packages/ui-default/templates/main.html new file mode 100644 index 000000000..32eccc365 --- /dev/null +++ b/packages/ui-default/templates/main.html @@ -0,0 +1,210 @@ +{% set no_path_section = true %} +{% set page_name = "homepage" %} +{% extends "layout/basic.html" %} +{% import "components/contest.html" as contest with context %} +{% block content %} +{% if domain['bulletin'] %} +
    +
    +
    +
    + {{ domain['bulletin']|markdown|safe }} +
    +
    +
    +
    +{% endif %} +
    +
    + {% if tdocs.length %} +
    +
    +

    {{ _('Contest') }}

    +
    +
      + {%- for tdoc in tdocs -%} +
    1. +
      +
      +
      +
      {{ datetimeSpan(tdoc['beginAt'], false, '%d')|safe }}
      +
      {{ datetimeSpan(tdoc['beginAt'], false, '%Y-%m')|safe }}
      +
      +
      +
      +

      {{ tdoc.title }}

      +
        +
      • + {{ model.contest.RULES[tdoc.rule].TEXT }} +
      • +
      • + {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} +
      • +
      • + {{ tdoc['attend']|default(0) }} +
      • + {% if tsdict[tdoc.docId]['attend'] == 1 %} +
      • + {{ _('Attended') }} +
      • + {% endif %} +
      +
      +
      +
    2. + {%- endfor -%} +
    +
    + {% endif %} + {% if htdocs.length %} +
    +
    +

    {{ _('Homework') }}

    +
    +
      + {%- for tdoc in htdocs -%} +
    1. +
      +
      +
      +
      {{ datetimeSpan(tdoc['penaltySince'], false, '%d')|safe }}
      +
      {{ datetimeSpan(tdoc['penaltySince'], false, '%Y-%m')|safe }}
      +
      +
      +
      +

      {{ tdoc['title'] }}

      +
        +
      • + {{ _('Status') }}: {{ _(model.contest.statusText(tdoc)) }} +
      • +
      • + {{ _('Open Since') }}: {{ datetimeSpan(tdoc['beginAt'])|safe }} +
      • +
      • + {% if model.contest.isExtended(tdoc) or model.contest.isDone(tdoc) %} + {{ _('Hard Deadline') }}: {{ datetimeSpan(tdoc['endAt'])|safe }} + {% else %} + {{ _('Deadline') }}: {{ datetimeSpan(tdoc['penaltySince'])|safe }} + {% endif %} +
      • + {% if htsdict[tdoc['docId']]['attend'] == 1 %} +
      • + {{ _('Claimed') }} +
      • + {% endif %} +
      +
      +
      +
    2. + {%- endfor -%} +
    +
    + {% endif %} + {% if trdocs.length %} +
    +
    +

    {{ _('Training') }}

    +
    + {% set tdocs = trdocs %} + {% set tsdict = trsdict %} +
      + {%- for tdoc in tdocs -%} +
    1. +
      +
      +
      +
      {{ tdoc['enroll']|default(0) }}
      +
      {{ _('Enrolled') }}
      +
      +
      +
      +

      {{ tdoc.title }}

      +
      +

      {{ tdoc['content'] }}

      +
      +
        +
      • + {{ _('{0} sections').format(tdoc['dag']|length) }}, {{ _('{0} problems').format(model.training.getPids(tdoc.dag)|length) }} +
      • +
      • + {% if tsdict[tdoc.docId]['enroll'] %} + {% if not tsdict[tdoc.docId]['done'] %} + + {{ _('Completed') }} {{ (100 * tsdict[tdoc.docId]['donePids']|length / model.training.getPids(tdoc.dag)|length)|round|int }}% + {% else %} + + {{ _('Completed') }} 100% + {% endif %} + {% elif handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + {{ _('Not Enrolled') }} + {% endif %} +
      • +
      +
      +
      +
    2. + {%- endfor -%} +
    +
    + {% endif %} + {% if ddocs.length %} +
    +
    +

    {{ _('Discussion') }}

    +
    + {% include "partials/discussion_list.html" %} +
    + {% endif %} +
    +
    +
    +
    +

    {{ _('Hitokoto') }}

    +
    +
    +
    + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +
    +
    +

    {{ _('Check In') }}

    +
    +
    还没做好(前端不会做欢迎来帮忙)
    +
    + {% endif %} + {% if handler.user.hasPerm(perm.PERM_VIEW_DISCUSSION) %} + {% include 'partials/discussion_nodes_widget.html' %} + {% endif %} +
    +
    +

    {{ _('Recommended') }}

    +
    +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/manage_base.html b/packages/ui-default/templates/manage_base.html new file mode 100644 index 000000000..2751e3c69 --- /dev/null +++ b/packages/ui-default/templates/manage_base.html @@ -0,0 +1,25 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    + {% block manage_content %}{% endblock %} +
    +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/manage_dashboard.html b/packages/ui-default/templates/manage_dashboard.html new file mode 100644 index 000000000..aa6de91d4 --- /dev/null +++ b/packages/ui-default/templates/manage_dashboard.html @@ -0,0 +1,27 @@ +{% import "components/user.html" as user %} +{% extends "manage_base.html" %} +{% block manage_content %} +
    +
    +

    {{ _('Messages') }}

    +
    +
    +
    +
    +
    +

    {{ _('Information') }}

    +
    +
    + + +
    +
    +
    +
    +
    +
    {{ _('Gravatar') }}
    +
    {{ _('Name') }}
    {{ domain.name }}
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/manage_script.html b/packages/ui-default/templates/manage_script.html new file mode 100644 index 000000000..ec6636ccf --- /dev/null +++ b/packages/ui-default/templates/manage_script.html @@ -0,0 +1,56 @@ +{% extends "manage_base.html" %} +{% block manage_content %} + +
    +
    +

    {{ _('Scripts') }}

    + +
    +
    + {{ noscript_note.render() }} + + + + + + + + + + + + + + + {%- for id, script in scripts -%} + {% if not script.hidden %} + + + + + + {% endif %} + {%- endfor -%} + +
    {{ _('ID') }}{{ _('Description') }}{{ _('Action') }}
    {{ id }}{{ _(script.description|default(_('None'))) }} + {{ _('Run') }} +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/manage_setting.html b/packages/ui-default/templates/manage_setting.html new file mode 100644 index 000000000..0147a7b64 --- /dev/null +++ b/packages/ui-default/templates/manage_setting.html @@ -0,0 +1,6 @@ +{% extends "manage_base.html" %} +{% block manage_content %} +
    + {% include "partials/setting.html" %} +
    +{% endblock %} diff --git a/packages/ui-default/templates/manage_user_import.html b/packages/ui-default/templates/manage_user_import.html new file mode 100644 index 000000000..f9902f606 --- /dev/null +++ b/packages/ui-default/templates/manage_user_import.html @@ -0,0 +1,22 @@ +{% import "components/user.html" as user %} +{% extends "manage_base.html" %} +{% block manage_content %} +
    +
    +

    {{ _('Import User') }}

    +
    +
    + {{ noscript_note.render() }} +

    + {{ form.form_textarea({ + columns:null, + label:'Users', + name:'users' + }) }} +
    + + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/partials/category.html b/packages/ui-default/templates/partials/category.html new file mode 100644 index 000000000..d7ec78f0b --- /dev/null +++ b/packages/ui-default/templates/partials/category.html @@ -0,0 +1,18 @@ +
      + {%- for category, sub_categories in model.system.get('problem.categories')|parseYaml -%} +
    • +

      + {{ category }} +

      + {% if sub_categories | length > 0 %} +
        + {%- for sub_category in sub_categories -%} +
      1. + {{ sub_category }} +
      2. + {%- endfor -%} +
      + {% endif %} +
    • + {%- endfor -%} +
    \ No newline at end of file diff --git a/packages/ui-default/templates/partials/contest_sidebar.html b/packages/ui-default/templates/partials/contest_sidebar.html new file mode 100644 index 000000000..d22bd97ec --- /dev/null +++ b/packages/ui-default/templates/partials/contest_sidebar.html @@ -0,0 +1,92 @@ +{% import "components/user.html" as user with context %} +{% import "components/contest.html" as contest with context %} +
    +{% if page_name != 'contest_detail' %} + +
    +

    {{ tdoc.title }}

    +
    + {% if attended %} + {{ _('Attended') }} + {% else %} + {{ _('Not Attended') }} + {% endif %} +
    +
    +
    +{% endif %} +
    + +
    +
    +
    +
    {{ _('Status') }}
    {{ _(model.contest.statusText(tdoc)) }}
    +
    {{ _('Rule') }}
    {{ model.contest.RULES[tdoc.rule].TEXT }}
    +
    {{ _('Problem') }}
    {{ tdoc.pids.length }}
    +
    {{ _('Start at') }}
    {{ contest.render_time(tdoc.beginAt) }}
    +
    {{ _('End at') }}
    {{ contest.render_time(tdoc.endAt) }}
    +
    {{ _('Duration') }}
    {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }}
    +
    {{ _('Host') }}
    {{ user.render_inline(owner_udoc, badge=false) }}
    +
    {{ _('Partic.') }}
    +
    + {{ tdoc['attend']|default(0) }} + {% if attended %} + ({{ _('Attended') }}) + {% endif %} +
    +
    +
    +
    diff --git a/packages/ui-default/templates/partials/discussion_edit_form.html b/packages/ui-default/templates/partials/discussion_edit_form.html new file mode 100644 index 000000000..5ef1e4587 --- /dev/null +++ b/packages/ui-default/templates/partials/discussion_edit_form.html @@ -0,0 +1,46 @@ +{% import "components/form.html" as form with context %} +
    +
    + +
    +
    + +
    +
    + +
    +
    +{{ form.form_textarea({ + columns:12, + label:'Content', + name:'content', + value:ddoc.content|default(''), + hotkeys:'ctrl+enter:submit', + markdown:true, + required:true, + extra_style:'height: 500px' + }) }} diff --git a/packages/ui-default/templates/partials/discussion_list.html b/packages/ui-default/templates/partials/discussion_list.html new file mode 100644 index 000000000..dc52c7f5b --- /dev/null +++ b/packages/ui-default/templates/partials/discussion_list.html @@ -0,0 +1,46 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/user.html" as user with context %} +{% import "components/paginator.html" as paginator with context %} +{% if not ddocs.length %} + {{ nothing.render('no discussion yet...') }} +{% else %} +
      + {%- for ddoc in ddocs -%} +
    1. +
      +
      +
      +
      {{ ddoc.nReply }}
      +
      {{ _('Comments') }}
      +
      +
      + +
      +
    2. + {%- endfor -%} +
    + {% if page != undefined and dpcount != undefined %} + {{ paginator.render(page, dpcount) }} + {% endif %} +{% endif %} diff --git a/packages/ui-default/templates/partials/discussion_nodes_widget.html b/packages/ui-default/templates/partials/discussion_nodes_widget.html new file mode 100644 index 000000000..0e40c458a --- /dev/null +++ b/packages/ui-default/templates/partials/discussion_nodes_widget.html @@ -0,0 +1,17 @@ +
    +
    +

    {{ _('Discussion Nodes') }}

    +
    +
      + {%- for category, nodes in vnodes|groupby('content') -%} +
    • +

      {{ category }}

      +
        + {%- for node in nodes -%} +
      1. {{ node.docId }}
      2. + {%- endfor -%} +
      +
    • + {%- endfor -%} +
    +
    \ No newline at end of file diff --git a/packages/ui-default/templates/partials/footer.html b/packages/ui-default/templates/partials/footer.html new file mode 100644 index 000000000..cdff942e9 --- /dev/null +++ b/packages/ui-default/templates/partials/footer.html @@ -0,0 +1,75 @@ +{% if not isIE(handler.request.headers['user-agent']) %} + +{% endif %} diff --git a/packages/ui-default/templates/partials/hamburger.html b/packages/ui-default/templates/partials/hamburger.html new file mode 100644 index 000000000..d7ad263c2 --- /dev/null +++ b/packages/ui-default/templates/partials/hamburger.html @@ -0,0 +1,7 @@ + diff --git a/packages/ui-default/templates/partials/header.html b/packages/ui-default/templates/partials/header.html new file mode 100644 index 000000000..8ba1591ff --- /dev/null +++ b/packages/ui-default/templates/partials/header.html @@ -0,0 +1,6 @@ +
    +
    + +
    + {% include "partials/path.html" %} +
    diff --git a/packages/ui-default/templates/partials/header_mobile.html b/packages/ui-default/templates/partials/header_mobile.html new file mode 100644 index 000000000..266770a73 --- /dev/null +++ b/packages/ui-default/templates/partials/header_mobile.html @@ -0,0 +1,12 @@ +{% if no_path_section %} +
    +
    +
    +   +
    +
    + {% include "partials/hamburger.html" %} +
    +
    +
    +{% endif %} diff --git a/packages/ui-default/templates/partials/homework_default_penalty_rules.yaml b/packages/ui-default/templates/partials/homework_default_penalty_rules.yaml new file mode 100644 index 000000000..689730941 --- /dev/null +++ b/packages/ui-default/templates/partials/homework_default_penalty_rules.yaml @@ -0,0 +1,6 @@ +# Format: +# hours: coefficient +1: 0.9 +3: 0.8 +12: 0.75 +9999: 0.5 diff --git a/packages/ui-default/templates/partials/homework_sidebar.html b/packages/ui-default/templates/partials/homework_sidebar.html new file mode 100644 index 000000000..2ab5f5d2a --- /dev/null +++ b/packages/ui-default/templates/partials/homework_sidebar.html @@ -0,0 +1,85 @@ +{% import "components/user.html" as user with context %} +{% import "components/homework.html" as homework with context %} +
    +{% if page_name != 'homework_detail' %} + +
    +

    {{ tdoc['title'] }}

    +
    + {% if attended %} + {{ _('Claimed') }} + {% else %} + {{ _('Not Claimed') }} + {% endif %} +
    +
    +
    +{% endif %} +
    + +
    +
    +
    +
    {{ _('Status') }}
    {{ _(model.contest.statusText(tdoc)) }}
    +
    {{ _('Problem') }}
    {{ tdoc['pids']|length }}
    +
    {{ _('Open Since') }}
    {{ homework.render_time(tdoc['beginAt']) }}
    +
    {{ _('Deadline') }}
    {{ homework.render_time(tdoc['penaltySince']) }}
    +
    {{ _('Can be Extended For') }}
    {{ homework.render_extension(tdoc) }} {{ _('hour(s)') }}
    +
    +
    +
    \ No newline at end of file diff --git a/packages/ui-default/templates/partials/login_dialog.html b/packages/ui-default/templates/partials/login_dialog.html new file mode 100644 index 000000000..d84a46b02 --- /dev/null +++ b/packages/ui-default/templates/partials/login_dialog.html @@ -0,0 +1,53 @@ + diff --git a/packages/ui-default/templates/partials/nav.html b/packages/ui-default/templates/partials/nav.html new file mode 100644 index 000000000..42faaaa66 --- /dev/null +++ b/packages/ui-default/templates/partials/nav.html @@ -0,0 +1,77 @@ +{%- macro nav_item(target_page_name, target_page_args, target_page_prefix) -%} + +{%- endmacro -%} + + + diff --git a/packages/ui-default/templates/partials/path.html b/packages/ui-default/templates/partials/path.html new file mode 100644 index 000000000..6dbc36085 --- /dev/null +++ b/packages/ui-default/templates/partials/path.html @@ -0,0 +1,19 @@ +
    +{% if not no_path_section %} +
    +
    +
    +
    + {%- for c in path -%} + / {% if loop.index < path.length %}{{ c[0] if c[3] else _(c[0]) }}{% endif %} + {%- endfor %} +
    +

    {{ path[path.length - 1][0] if path[path.length - 1][3] else _(path[path.length - 1][0]) }}

    +
    +
    + {% include "partials/hamburger.html" %} +
    +
    +
    +{% endif %} +
    diff --git a/packages/ui-default/templates/partials/problem_default.md b/packages/ui-default/templates/partials/problem_default.md new file mode 100644 index 000000000..a47706a29 --- /dev/null +++ b/packages/ui-default/templates/partials/problem_default.md @@ -0,0 +1,30 @@ +# Background +Special for beginners, ^_^ + +# Description +Given two integers x and y, print the sum. + +# Format + +## Input +Two integers x and y, satisfying $0\leq x,y\leq 32767$ . + +## Output +One integer, the sum of x and y. + +# Sample 1 + +## Input + +``` +123 500 +``` + +## Output + +``` +623 +``` + +# Limitation +1s, 1024KiB for each test case. diff --git a/packages/ui-default/templates/partials/problem_list.html b/packages/ui-default/templates/partials/problem_list.html new file mode 100644 index 000000000..38a152bae --- /dev/null +++ b/packages/ui-default/templates/partials/problem_list.html @@ -0,0 +1,54 @@ +{% import "components/paginator.html" as paginator with context %} +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} +{% import "components/nothing.html" as nothing with context %} +
    +{% if not pdocs.length %} + {{ nothing.render('Sorry, there is no problem in the problem set') }} +{% else %} + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + {% endif %} + + + + + + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + {% endif %} + + + + + + + + {%- for pdoc in pdocs -%} + {% set psdoc = psdict[pdoc['docId']] %} + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + {% if psdoc['rid'] %} + {{ record.render_status_td(psdoc, rid_key='rid') }} + {% else %} + + {% endif %} + {% endif %} + + + + + + {%- endfor -%} + +
    {{ _('Status') }}{{ _('Problem') }}{{ _('Submit') }}% {{ _('AC') }}{{ _('Difficulty') }}
    + + {{ problem.render_problem_title(pdoc) }} + {{ pdoc['nSubmit'] }}{{ (100 * pdoc['nAccept'] / pdoc['nSubmit'])|round|int if pdoc['nSubmit'] > 0 else _('?') }}{{ pdoc['difficulty'] or _('(None)') }}
    + {{ paginator.render(page, ppcount) }} +{% endif %} +
    diff --git a/packages/ui-default/templates/partials/problem_lucky.html b/packages/ui-default/templates/partials/problem_lucky.html new file mode 100644 index 000000000..19e5c466d --- /dev/null +++ b/packages/ui-default/templates/partials/problem_lucky.html @@ -0,0 +1,10 @@ +
    +

    + + {{ _('Lucky') }} + +

    +
    +

    {{ _('Pick a problem randomly based on current filter') }}

    +
    +
    diff --git a/packages/ui-default/templates/partials/problem_sidebar.html b/packages/ui-default/templates/partials/problem_sidebar.html new file mode 100644 index 000000000..8bc871602 --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar.html @@ -0,0 +1,17 @@ +{% if tdoc %} + {% if tdoc.docType == 60 %} + {% include "partials/problem_sidebar_homework.html" %} + {% if not owner_udoc %} + {% set owner_udoc = udoc %} + {% endif %} + {% include "partials/homework_sidebar.html" %} + {% else %} + {% include "partials/problem_sidebar_contest.html" %} + {% if not owner_udoc %} + {% set owner_udoc = udoc %} + {% endif %} + {% include "partials/contest_sidebar.html" %} + {% endif %} +{% else %} + {% include "partials/problem_sidebar_normal.html" %} +{% endif %} diff --git a/packages/ui-default/templates/partials/problem_sidebar_contest.html b/packages/ui-default/templates/partials/problem_sidebar_contest.html new file mode 100644 index 000000000..9bee23a30 --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_contest.html @@ -0,0 +1,42 @@ +{% import "components/user.html" as user with context %} +
    +{% if page_name != 'problem_detail' and page_name != 'contest_detail_problem' %} +
    +

    {{ pdoc['title'] }}

    +
    +{% endif %} +
    + +
    +
    diff --git a/packages/ui-default/templates/partials/problem_sidebar_homework.html b/packages/ui-default/templates/partials/problem_sidebar_homework.html new file mode 100644 index 000000000..eba889f98 --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_homework.html @@ -0,0 +1,42 @@ +{% import "components/user.html" as user with context %} +
    +{% if page_name != 'problem_detail' and page_name != 'homework_detail_problem' %} +
    +

    {{ pdoc['title'] }}

    +
    +{% endif %} +
    + +
    +
    \ No newline at end of file diff --git a/packages/ui-default/templates/partials/problem_sidebar_normal.html b/packages/ui-default/templates/partials/problem_sidebar_normal.html new file mode 100644 index 000000000..c6b72068a --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_normal.html @@ -0,0 +1,197 @@ +{% import "components/user.html" as user with context %} +{% import "components/problem.html" as problem with context %} +
    +{% if page_name != 'problem_detail' and page_name != 'contest_detail_problem' %} +
    +

    {{ pdoc['title'] }}

    +
    +{% endif %} +
    + +
    +
    + +
    +
    +

    {{ _('Information') }}

    +
    +
    +
    +
    {{ _('ID') }}
    +
    {{ pdoc.docId }}
    + {% if page_name != 'discussion_node' and page_name != 'discussion_detail' %} + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +
    {{ _('Submission') }}
    +
    + {% if pdoc.psdoc.status %} + + + {{ model.builtin.STATUS_TEXTS[pdoc['psdoc']['status']] }} + + {% else %} + {{ _('(None)') }} + {% endif %} + {% if pdoc['acMsg'] and pdoc['psdoc']['status'] == STATUS.STATUS_ACCEPTED %} +
    {{ _('AC Message') }}
    +
    {{ pdoc['acMsg'] }}
    + {% endif %} + + {% endif %} + {% endif %} +
    {{ _('Difficulty') }}
    +
    {{ pdoc['difficulty'] or _('(None)') }}
    +
    {{ _('Category') }}
    +
    + {% if pdoc['category'] %} + + {%- for category in pdoc['category'] -%} + {{ category }} + {% if loop.index < pdoc['category']|length %} + {{ _('problem-subcategory-delim') }} + {% endif %} + {%- endfor -%} + + + {{ _('page.problem_detail.sidebar.show_category') }} + + {% else %} + {{ _('(None)') }} + {% endif %} +
    +
    {{ _('Tags') }}
    +
    + {{ problem.render_problem_tags(pdoc, true) }} +
    +
    {{ _('# Submissions') }}
    +
    {{ pdoc.nSubmit }}
    + {% if page_name != 'discussion_node' and page_name != 'discussion_detail' %} + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +
    {{ _('# My Submissions') }}
    +
    {{ pdoc['psdoc'].nSubmit|default(0) }}
    + {% endif %} + {% endif %} +
    {{ _('Accepted') }}
    +
    {{ pdoc.nAccept }}
    +
    {{ _('Accepted Ratio') }}
    +
    {% if pdoc.nSubmit > 0 %}{{ (100 * pdoc.nAccept / pdoc.nSubmit)|round|int }}%{% else %}{{ _('?') }}{% endif %}
    +
    {{ _('Uploaded By') }}
    +
    {{ user.render_inline(owner_udoc, badge=false) }}
    +
    +
    +
    +{% if page_name == 'problem_detail' and (tdocs or ctdocs) %} +
    +
    +

    {{ _('Related') }}

    +
    +
    + {% if tdocs %} +

    {{ _('In following training plans') }}:

    + {%- for tdoc in tdocs -%} +

    {{ tdoc.title }}

    + {%- endfor -%} + {% endif %} + {% if ctdocs %} +

    {{ _('In following contests') }}:

    + {%- for tdoc in ctdocs -%} +

    {{ tdoc.title }}

    + {%- endfor -%} + {% endif %} +
    +
    +{% endif %} diff --git a/packages/ui-default/templates/partials/problem_stat.html b/packages/ui-default/templates/partials/problem_stat.html new file mode 100644 index 000000000..56604e18d --- /dev/null +++ b/packages/ui-default/templates/partials/problem_stat.html @@ -0,0 +1,3 @@ +
    +

    {{ _('{0} problems').format(pcount) }}

    +
    \ No newline at end of file diff --git a/packages/ui-default/templates/partials/setting.html b/packages/ui-default/templates/partials/setting.html new file mode 100644 index 000000000..7744009b2 --- /dev/null +++ b/packages/ui-default/templates/partials/setting.html @@ -0,0 +1,90 @@ +
    +{%- for family, family_settings in settings|groupby('family') -%} +
    +

    {{ _(family) }}

    +
    +
    + {%- for setting in family_settings -%} + {% if not setting.flag|bitand(model.setting.FLAG_HIDDEN) %} + {% set secret = setting.flag|bitand(model.setting.FLAG_SECRET) != 0 %} + {% if setting.type == 'text' or setting.type == 'password' or setting.type == 'number' %} + {{ form.form_text({ + type:setting.type, + label:setting.name, + help_text:setting.desc, + name:setting.key, + value:'' if secret else (current[setting.key]|default(setting.value)), + disabled:setting.disabled, + placeholder:_('(Not changed)') if secret else '' + }) }} + {% elif setting.type == 'select' %} + {{ form.form_select({ + options:setting.range, + label:setting.name, + help_text:setting.desc, + name:setting.key, + value:current[setting.key] or setting.value, + disabled:setting.disabled + }) }} + {% elif setting.type == 'radio' %} + {{ form.form_radio({ + options:setting.range, + label:setting.name, + help_text:setting.desc, + name:setting.key, + value:current[setting.key] or setting.value, + disabled:setting.disabled + }) }} + {% elif setting.type == 'image_radio' %} + {{ form.form_image_radio({ + options:setting.range, + image_class:setting.image_class, + label:setting.name, + help_text:setting.desc, + name:setting.key, + value:current[setting.key] or setting.value, + disabled:setting.disabled + }) }} + {% elif setting.type == 'textarea' %} + {{ form.form_textarea({ + label:setting.name, + help_text:setting.desc, + name:setting.key, + value:'' if secret else (current[setting.key]|default(setting.value)), + disabled:setting.disabled, + placeholder:_('(Not changed)') if secret else '' + }) }} + {% elif setting.type == 'markdown' %} + {{ form.form_textarea({ + label:setting.name, + help_text:setting.desc, + name:setting.key, + value:'' if secret else (current[setting.key]|default(setting.value)), + markdown:true, + disabled:setting.disabled, + placeholder:_('(Not changed)') if secret else '' + }) }} + {% elif setting.type == 'boolean' %} + {{ form.form_checkbox({ + label:setting.name, + help_text:setting.desc, + name:setting.key, + value:current[setting.key] or setting.value, + disabled:setting.disabled + })}} + {{ form.form_checkbox({ + label:setting.name, + name:'booleanKeys.' + setting.key, + value:true, + extra_class:'display-hidden' + })}} + {% endif %} + {% endif %} + {%- endfor -%} +
    +{%- endfor -%} +
    + + +
    +
    \ No newline at end of file diff --git a/packages/ui-default/templates/partials/training_default.json b/packages/ui-default/templates/partials/training_default.json new file mode 100644 index 000000000..f39debcae --- /dev/null +++ b/packages/ui-default/templates/partials/training_default.json @@ -0,0 +1,14 @@ +[ + { + "_id": 1, + "title": "最初的最初 - A+B Problem", + "requireNids": [], + "pids": [1000, "56c52ade2fefd90539000005"] + }, + { + "_id": 2, + "title": "最初的进阶", + "requireNids": [1], + "pids": [1001, 1002] + } +] diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html new file mode 100644 index 000000000..e1f3f111a --- /dev/null +++ b/packages/ui-default/templates/problem_detail.html @@ -0,0 +1,54 @@ +{% extends "layout/basic.html" %} +{% block content %} + +
    +
    +
    +
    +
    +

    {{ pdoc.title }}

    +
    +
    + {% if tdoc %} + {% if tdoc.docType == model.document.TYPE_CONTEST %} + {% if model.contest.isDone(tdoc) %} +
    +

    {{ _('You cannot submit for this problem because the contest is ended. You can click "Open in Problem Set" to view this problem in normal mode.') }}

    +
    + {% endif %}{# handler.isDone(tdoc) #} + {% endif %}{# tdoc['docType'] == model.document.TYPE_CONTEST #} + {% endif %}{# tdoc #} + {{ pdoc.content|content(handler.user.viewLang, pdoc.html)|safe }} +
    +
    +
    +
    +
    + {% set owner_udoc = udoc %} + {% include "partials/problem_sidebar.html" %} +
    +
    + +{% endblock %} diff --git a/packages/ui-default/templates/problem_edit.html b/packages/ui-default/templates/problem_edit.html new file mode 100644 index 000000000..c6a4b83c4 --- /dev/null +++ b/packages/ui-default/templates/problem_edit.html @@ -0,0 +1,84 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + {{ form.form_text({ + columns:12, + label:'Tags', + help_text:'Split by \', \'.', + name:'tag', + value:pdoc['tag']|default([])|join(', ') + }) }} +
    + +
    +
    + + {% if page_name == 'problem_edit' %} + + {% if pdoc.owner == handler.user._id or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) %} + + {% endif %} + {% else %} + + {% endif %} +
    +
    +
    +
    +
    +
    +
    +
    +

    {{ _('Categories') }} ({{ _('click to add') }})

    +
    +
    + {% include "partials/category.html" %} +
    +
    + {% if page_name == 'problem_edit' %} + {% set owner_udoc = udoc %} + {% include "partials/problem_sidebar.html" %} + {% else %} + {% include 'components/md_hint.html' %} + {% endif %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/problem_files.html b/packages/ui-default/templates/problem_files.html new file mode 100644 index 000000000..c69e28776 --- /dev/null +++ b/packages/ui-default/templates/problem_files.html @@ -0,0 +1,133 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    {{ _('Testdata') }}

    +
    + {% if handler.user._id == pdoc.owner or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) %} + + {% endif %} +
    +
    + {{ noscript_note.render() }} +
    + {% if testdata.length %} + + + + + + + + + + + + + + + {%- for file in testdata -%} + + + + + + {%- endfor -%} + +
    + + {{ _('Filename') }}{{ _('Size') }}
    + + + {{ file.name }} + {{ size(file.size) }}
    + {% else %} + {% if handler.user._id == pdoc.owner or handler.user.hasPerm(perm.PERM_READ_PROBLEM_DATA) %} + {{ nothing.render('No testdata at current.') }} + {% else %} + {{ nothing.render("You don't have permission to read testdata.")}} + {% endif %} + {% endif %} +
    + {% if handler.user._id == pdoc.owner or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) %} + {% if testdata.length %} +
    + + +
    + {% endif %} + {% endif %} +
    +
    +
    +
    +
    +

    {{ _('Additional Files') }}

    +
    + {% if handler.user._id == pdoc.owner or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) %} + + {% endif %} +
    +
    +
    + {% if additional_file.length %} + + + + + + + + + + + + + + + {%- for file in additional_file -%} + + + + + + {%- endfor -%} + +
    + + {{ _('Filename') }}{{ _('Size') }}
    + + + {{ file.name }} + {{ size(file.size) }}
    + {% else %} + {{ nothing.render('No additional file at current.') }} + {% endif %} +
    + {% if additional_file.length %} +
    + + {% if handler.user._id == pdoc.owner or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) %} + + {% endif %} +
    + {% endif %} +
    +
    +
    + {% set owner_udoc = udoc %} + {% include "partials/problem_sidebar.html" %} +
    +
    + +{% endblock %} diff --git a/packages/ui-default/templates/problem_import.html b/packages/ui-default/templates/problem_import.html new file mode 100644 index 000000000..700de5597 --- /dev/null +++ b/packages/ui-default/templates/problem_import.html @@ -0,0 +1,26 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    + +

    {{ _('Upload zipfile') }} ({{ _('Type') }}: {{type}}): + + +

    +
    +
    +
    +
    +
    +
    +
    +

    {{ _('What is this?') }}

    +

    {{ _('With this feature, you can import problems that you can view from a site to here. Their title, content, tags and categories will be imported.') }}

    +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/problem_import_syzoj.html b/packages/ui-default/templates/problem_import_syzoj.html new file mode 100644 index 000000000..2c64ec911 --- /dev/null +++ b/packages/ui-default/templates/problem_import_syzoj.html @@ -0,0 +1,52 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +

    {{ _('What is this?') }}

    +

    {{ _('With this feature, you can import problems that you can view from a site to here. Their title, content, tags and categories will be imported. However, their test data are not copied directly.') }}

    +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/problem_main.html b/packages/ui-default/templates/problem_main.html new file mode 100644 index 000000000..324024286 --- /dev/null +++ b/packages/ui-default/templates/problem_main.html @@ -0,0 +1,71 @@ +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} +{% extends "layout/basic.html" %} +{% block content %} + {{ set(UiContext, { + getProblemUrlWithCategory: url('problem_category', category='{category}'), + getProblemUrlWithoutCategory: url('problem_main'), + currentCategory: category + }) }} +
    +
    +
    +
    + {% include "partials/problem_list.html" %} +
    +
    +
    +
    +
    +
    +

    {{ _('Categories') }}

    +
    +
    + {% include "partials/category.html" %} +
    +
    + {% include "partials/problem_lucky.html" %} +
    +
    +
    +
    +

    {{ _('Search') }}

    +
    +
    +
    + + +
    +
    +
    + {% if handler.user.hasPerm(perm.PERM_CREATE_PROBLEM) %} +
    +
    +

    {{ _('Create Problem') }}

    +
    + +
    +
    +

    {{ _('You have permissions to create problems for this domain.') }}

    +
    +
    +
    + {% endif %} +
    +
    +

    {{ _('Statistics') }}

    +
    + {% include "partials/problem_stat.html" %} +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/problem_receive.html b/packages/ui-default/templates/problem_receive.html new file mode 100644 index 000000000..f137a5f07 --- /dev/null +++ b/packages/ui-default/templates/problem_receive.html @@ -0,0 +1,47 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    {{ _('Requests') }}

    +
    +
    + {{ noscript_note.render() }} + + + + + + + + + + + + + + + {%- for request in requests -%} + {%- for pid in request.pids -%} + + + + + + {%- endfor -%} + {%- endfor -%} + +
    {{ _('ID') }}{{ _('Title') }}{{ _('Action') }}
    {{ request.display }}{{ pid }}{{ request.pdocs[pid] }} +
    + + + + +
    +
    +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/problem_solution.html b/packages/ui-default/templates/problem_solution.html new file mode 100644 index 000000000..c89baa7bd --- /dev/null +++ b/packages/ui-default/templates/problem_solution.html @@ -0,0 +1,48 @@ +{% extends "layout/basic.html" %} +{% import "components/comments_solution.html" as comments with context %} +{% block content %} +
    +
    +
    +
    +

    {{ _('{0} solutions').format(pscount) }}

    +
    +
    + {{ comments.render( + view = 'solution', + docs = psdocs, + udict = udict, + comment_ref = 'psid', + reply_ref = 'psrid', + comment_placeholder = 'Write Your Solution', + comment_post_op = 'submit', + comment_edit_op = 'edit_solution', + comment_post_text = 'Share', + reply_post_op = 'reply', + reply_edit_op = 'edit_reply', + comment_delete_op = 'delete_solution', + reply_delete_op = 'delete_reply', + comment_post_perm = perm.PERM_CREATE_PROBLEM_SOLUTION, + comment_edit_perm = perm.PERM_NEVER, + comment_edit_self_perm = perm.PERM_EDIT_PROBLEM_SOLUTION_SELF, + comment_delete_perm = perm.PERM_DELETE_PROBLEM_SOLUTION, + comment_delete_self_perm = perm.PERM_DELETE_PROBLEM_SOLUTION_SELF, + reply_post_perm = perm.PERM_REPLY_PROBLEM_SOLUTION, + reply_edit_perm = perm.PERM_NEVER, + reply_edit_self_perm = perm.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF, + reply_delete_perm = perm.PERM_DELETE_PROBLEM_SOLUTION_REPLY, + reply_delete_self_perm = perm.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF + ) }} + {{ paginator.render(page, pcount) }} + {% if drcount == 0 %} + {{ nothing.render('No solutions so far...') }} + {% endif %} +
    +
    +
    +
    + {% set owner_udoc = udict[pdoc.owner] %} + {% include "partials/problem_sidebar.html" %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/problem_statistics.html b/packages/ui-default/templates/problem_statistics.html new file mode 100644 index 000000000..ce67d0ddf --- /dev/null +++ b/packages/ui-default/templates/problem_statistics.html @@ -0,0 +1,20 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    {{ _('Submission Statistics') }}

    +
    +
    + {{ _('Swx Template') }} +
    +
    +
    +
    + {% set owner_udoc = udoc %} + {% include "partials/problem_sidebar.html" %} + {% endwith %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/problem_submit.html b/packages/ui-default/templates/problem_submit.html new file mode 100644 index 000000000..f23d3710c --- /dev/null +++ b/packages/ui-default/templates/problem_submit.html @@ -0,0 +1,66 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    + {% if not tdoc or rdocs.length %} +
    +
    +

    {{ _('Recent 10 Records') }}

    +
    +
    + {% if not rdocs.length %} + {{ nothing.render('You have not submitted any solutions for this problem') }} + {% else %} + + + + + + + + + + + + + + + + + {%- for rdoc in rdocs -%} + {% include 'problem_submit_tr.html' %} + {%- endfor -%} + +
    {{ _('Status') }}{{ _('Peak Memory') }}{{ _('Total Time') }}{{ _('Submit At') }}
    + {% endif %} +
    +
    + {% endif %} +
    +
    +

    {{ _('Submit to Judge') }}

    +
    +
    +
    + {{ form.form_select({ + label:'Code language', + options:model.system.get('lang.texts')|parseYaml, + name:'lang', + value:handler.user.codeLang + }) }} + {{ form.form_textarea({label:'Code', columns:12, name:'code', extra_class:'monospace', autofocus:true}) }} +
    + + +
    +
    +
    +
    +
    +
    + {% set owner_udoc = udoc %} + {% include "partials/problem_sidebar.html" %} +
    +
    + +{% endblock %} diff --git a/packages/ui-default/templates/problem_submit_tr.html b/packages/ui-default/templates/problem_submit_tr.html new file mode 100644 index 000000000..6662c8570 --- /dev/null +++ b/packages/ui-default/templates/problem_submit_tr.html @@ -0,0 +1,11 @@ +{% import "components/record.html" as record with context %} + + {{ record.render_status_td(rdoc) }} + + {{ (rdoc.memory / 1000)|round(0, 'ceil')|int }} MB + + + {{ rdoc.time|round|int }}ms + + {{ datetimeSpan(rdoc._id)|safe }} + diff --git a/packages/ui-default/templates/problem_upload.html b/packages/ui-default/templates/problem_upload.html new file mode 100644 index 000000000..8079130d4 --- /dev/null +++ b/packages/ui-default/templates/problem_upload.html @@ -0,0 +1,16 @@ +{% extends 'layout/simple.html' %} +{% block body %} +
    + {% if (typeof(pdoc.data) == 'string') %} +

    {{ _('Current dataset comes from') }} {{ pdoc.data.split('from:')[1] }}

    + {% else %} +

    {{ _('Current dataset: {0}').format((pdoc['data'] or _('(None)')).toString()) }}

    +

    {{ _('MD5') }}: {{ md5|default(_('(None)')) }}

    + {% endif %} +

    {{ _('New dataset') }}: + + + +

    +
    +{% endblock %} diff --git a/packages/ui-default/templates/ranking.html b/packages/ui-default/templates/ranking.html new file mode 100644 index 000000000..f6f6e0cfb --- /dev/null +++ b/packages/ui-default/templates/ranking.html @@ -0,0 +1,46 @@ +{% extends "layout/basic.html" %} +{% import "components/paginator.html" as paginator with context %} +{% import "components/nothing.html" as nothing with context %} +{% block content %} +
    +
    +
    +
    +
    + {% if not udocs.length %} + {{ nothing.render('Sorry, there is no user in this domain.') }} + {% else %} + + + + + + + + + + + + + + + + + {%- for udoc in udocs -%} + + + + + + + {%- endfor -%} + +
    {{ _('Rank') }}{{ _('Username') }}{{ _('RP') }}{{ _('Bio') }}
    {{ (page - 1) * 100 + loop.index }}{{ user.render_inline(udoc) }}{{ udoc.rp|default(1500)|round(0) }}{{ udoc.bio|default('')|truncate(64, true)|markdownInline|safe }}
    + {{ paginator.render(page, upcount) }} + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/record_detail.html b/packages/ui-default/templates/record_detail.html new file mode 100644 index 000000000..75d369ea3 --- /dev/null +++ b/packages/ui-default/templates/record_detail.html @@ -0,0 +1,107 @@ +{% import "components/problem.html" as problem with context %} +{% extends "layout/basic.html" %} +{% block content %} +{{ set(UiContext, 'socketUrl', "/record-detail-conn?domainId=" + rdoc.domainId + "&rid=" + rdoc._id) }} +
    +
    + {% include 'record_detail_status.html' %} + {% if rdoc['code'] %} +
    +
    +

    {{ _('Code') }}

    +
    +
    +
    {{ rdoc['code'] }}
    +
    +
    + {% endif %} + {% if rdoc['stdout'] or rdoc['stderr'] %} +
    +
    +

    {{ _('Stdout') }}

    +
    + {% if rdoc['stdout'] %} +
    +
    {{ rdoc['stdout']|ansi|safe }}
    +
    + {% endif %} + {% if rdoc['stderr'] %} +
    +
    {{ rdoc['stderr']|ansi|safe }}
    +
    + {% endif %} +
    + {% endif %} +
    +
    +
    +
    +

    {{ _('Information') }}

    +
    + {% if handler.user.hasPerm(perm.PERM_REJUDGE) %} +
    + +
    + {% endif %} +
    +
    +
    {{ _('Submit By') }}
    +
    {{ user.render_inline(udoc, modbadge=false) }}
    + {% if pdoc %} +
    {{ _('Problem') }}
    +
    + {{ problem.render_problem_title(pdoc, tdoc, show_tags=false, show_invisible_flag=false, rp=none) }} +
    + {% endif %} + {% if tdoc %} +
    {{ _('Contest') }}
    +
    + {{ tdoc.title }} +
    + {% endif %} +
    {{ _('Language') }}
    +
    + {{ (model.system.get('lang.texts')|parseYaml)[rdoc['lang']] }} +
    +
    + {{ _('Submit At') }} +
    +
    + {{ datetimeSpan(rdoc._id)|safe }} +
    + {% if rdoc.judgeAt %} +
    {{ _('Judged At') }}
    +
    {{ datetimeSpan(rdoc.judgeAt)|safe }}
    + {% endif %} + {% if judge_udoc %} +
    {{ _('Judged By') }}
    +
    {{ user.render_inline(judge_udoc, badge=false) }}
    + {% endif %} +
    + {% if show_status %} + {% include 'record_detail_summary.html' %} + {% endif %} +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/record_detail_status.html b/packages/ui-default/templates/record_detail_status.html new file mode 100644 index 000000000..59f846251 --- /dev/null +++ b/packages/ui-default/templates/record_detail_status.html @@ -0,0 +1,59 @@ +
    +
    +

    + + + {{ model.builtin.STATUS_TEXTS[rdoc['status']] }} + +

    +
    +{% if rdoc.compilerTexts.length %} +
    +
    {{ rdoc.compilerTexts|join('\n')|ansi|safe }}
    +
    +{% endif %} +{% if rdoc.judgeTexts.length %} +
    +
    {{ formatJudgeTexts(rdoc.judgeTexts)|ansi|safe }}
    +
    +{% endif %} +{% if rdoc.testCases.length %} +
    + + + + + + + + + + + + + + + + + {%- for rcdoc in rdoc.testCases -%} + + + + + + + {%- endfor -%} + +
    #{{ _('Status') }}{{ _('Time Cost') }}{{ _('Memory Cost') }}
    + #{{ loop.index }} + + + + {{ model.builtin.STATUS_TEXTS[rcdoc['status']] }} + + {{ formatJudgeTexts([rcdoc.message]) if rcdoc.message else '' }} + + {% if rcdoc['status'] == STATUS.STATUS_TIME_LIMIT_EXCEEDED or rcdoc['status'] == STATUS.STATUS_MEMORY_LIMIT_EXCEEDED or rcdoc['status'] == STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED %}≥{% endif %}{{ rcdoc.time|round|int }}ms{% if rcdoc['status'] == STATUS.STATUS_TIME_LIMIT_EXCEEDED or rcdoc['status'] == STATUS.STATUS_MEMORY_LIMIT_EXCEEDED or rcdoc['status'] == STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED %}≥{% endif %}{{ size(rcdoc.memory, 1024) }}
    +
    +{% endif %} +
    diff --git a/packages/ui-default/templates/record_detail_summary.html b/packages/ui-default/templates/record_detail_summary.html new file mode 100644 index 000000000..4944d4544 --- /dev/null +++ b/packages/ui-default/templates/record_detail_summary.html @@ -0,0 +1,8 @@ +
    +
    {{ _('Score') }}
    +
    {{ rdoc['score'] }}
    +
    {{ _('Total Time') }}
    +
    {% if rdoc['status'] == STATUS.STATUS_TIME_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_MEMORY_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED %}≥{% endif %}{{ rdoc.time|round|int }}ms
    +
    {{ _('Peak Memory') }}
    +
    {% if rdoc['status'] == STATUS.STATUS_TIME_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_MEMORY_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED %}≥{% endif %}{{ size(rdoc.memory, 1024) }}
    +
    diff --git a/packages/ui-default/templates/record_main.html b/packages/ui-default/templates/record_main.html new file mode 100644 index 000000000..e1f79267b --- /dev/null +++ b/packages/ui-default/templates/record_main.html @@ -0,0 +1,109 @@ +{% extends "layout/basic.html" %} +{% block content %} +{{ set(UiContext, 'socketUrl', "/record-conn?domainId=" + handler.domainId) }} +{{ set(UiContext, 'rids', rdocs.map(eval("rdoc=>rdoc._id.toString()"))) }} +
    +
    +
    +
    +
    +

    {{ _('Filter') }}

    +
    + + {{ _('Reset') }} +
    +
    +
    +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + {% if not rdocs %} + {% if not filterUidOrName and not filterPid and not filterTid %} + {{ nothing.render('Oh, there is no task in the queue!') }} + {% else %} + {{ nothing.render('Oh, there is no task that matches the filter!') }} + {% endif%} + {% else %} + + + + + + + + + + + + + + + + + + + + + + + {%- for rdoc in rdocs -%} + {% set udoc = udict[rdoc['uid']] %} + {% set pdoc = pdict[rdoc['pid']] %} + {% include 'record_main_tr.html' %} + {%- endfor -%} + +
    {{ _('Status') }}{{ _('Problem') }}{{ _('Submit By') }}{{ _('Time') }}{{ _('Memory') }}{{ _('Language') }}{{ _('Submit At') }}
    + {% if handler.user.hasPriv(PRIV.PRIV_VIEW_JUDGE_STATISTICS) %} + + {% endif %} + {% endif %} +
    +
    + {% if statistics %} +
    +
    +

    {{ _('Statistics') }}

    +
    +
    +
    +
    {{ _('5 Min')}}
    {{ statistics['d5min'] }}
    +
    {{ _('1 Hour')}}
    {{ statistics['d1h'] }}
    +
    {{ _('Day') }}
    {{ statistics['day'] }}
    +
    {{ _('Week') }}
    {{ statistics['week'] }}
    +
    {{ _('Month') }}
    {{ statistics['month'] }}
    +
    {{ _('Year') }}
    {{ statistics['year'] }}
    +
    {{ _('Total') }}
    {{ statistics['total'] }}
    +
    +
    +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/record_main_tr.html b/packages/ui-default/templates/record_main_tr.html new file mode 100644 index 000000000..1b7653d68 --- /dev/null +++ b/packages/ui-default/templates/record_main_tr.html @@ -0,0 +1,35 @@ +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} + + {{ record.render_status_td(rdoc) }} + + {% if handler.user.hasPerm(perm.PERM_REJUDGE) %} +
    + + + | +
    + {% endif %} + {% if pdoc and (not pdoc.hidden or handler.user.hasPerm(perm.PERM_VIEW_PROBLEM_HIDDEN)) %} + {{ problem.render_problem_title(pdoc, show_tags=false) }} + {% else %} + * + {% endif %} + + + + {% if udoc.displayName %} + {{ udoc.displayName }} ({{ udoc.uname }}) + {% else %} + {{ udoc.uname }} + {% endif %} + + + {% if rdoc['status'] == STATUS.STATUS_TIME_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_MEMORY_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED %}≥{% endif %}{{ rdoc.time|round|int }}ms + {% if rdoc['status'] == STATUS.STATUS_TIME_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_MEMORY_LIMIT_EXCEEDED or rdoc['status'] == STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED %}≥{% endif %}{{ size(rdoc.memory, 1024) }} + {{ (model.system.get('lang.texts')|parseYaml)[rdoc['lang']] }} + {{ datetimeSpan(rdoc['_id'])|safe }} + diff --git a/packages/ui-default/templates/status.html b/packages/ui-default/templates/status.html new file mode 100644 index 000000000..02b25f9df --- /dev/null +++ b/packages/ui-default/templates/status.html @@ -0,0 +1,45 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + {%- for doc in stats -%} + + + + + + + + + {%- endfor -%} + +
    {{ _('ID') }}{{ _('Current Status') }}{{ _('OS Info') }}{{ _('CPU Info') }}{{ _('Memory Info') }}{{ _('Handled requests') }}
    {{ doc._id.toHexString()|truncate(6,True,'') }}{% if doc.isOnline %}{{ doc.status }}{% else %}{{ datetimeSpan(doc.updateAt)|safe }}{% endif %}{{ doc.osinfo }}{{ doc.cpu }}{{ doc.memory }}{{ doc.reqCount }}
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/training_detail.html b/packages/ui-default/templates/training_detail.html new file mode 100644 index 000000000..492e34eec --- /dev/null +++ b/packages/ui-default/templates/training_detail.html @@ -0,0 +1,206 @@ +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    + {{ tdoc['content'] }} +
    +
    + {{ tdoc['desc']|default('')|markdown|safe }} +
    +
    +
    + {% if not tsdoc.enroll %} +
    +
    +

    {{ _('page.training_detail.invalid_when_not_enrolled') }}

    +
    +
    + {% endif %} + {%- for node in tdoc['dag'] -%} +
    +
    +
    +

    {{ _('Section') }} {{ node['_id'] }}. {{ node['title'] }}

    +
    +
    +

    + + {% if nsdict[node['_id']]['isDone'] %} + {{ _('Completed') }} + {% elif nsdict[node['_id']]['isProgress'] %} + {{ _('In Progress') }} + {% elif nsdict[node['_id']]['isOpen'] %} + {{ _('Open') }} + {% else %} + {{ _('Invalid') }} + {% endif %} +

    +
    +
    + +
    + {% if nsdict[node['_id']]['isInvalid'] %} +
    +
    +

    {{ _('This section cannot be challenged at present, so please complete the following sections first') }}:

    +
      + {%- for nid in node['requireNids'] -%} +
    • {{ _('Section') }} {{ _(nid) }}. {{ ndict[nid]['title'] }} ({{ _('Completed') }} {{ nsdict[nid]['progress'] }}%)
    • + {%- endfor -%} +
    +
    +
    + {% endif %} + {% if node['content'] %} +
    + {{ node['content']|markdown|safe }} +
    + {% endif %} +
    + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + {% endif %} + + + + + + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + {% endif %} + + + + + + + + {%- for pid in node['pids'] -%} + {% if pid in pdict %} + {% set pdoc=pdict[pid] %} + {% set psdoc=psdict[pid] %} + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + {% if psdoc['rid'] %} + {{ record.render_status_td(psdoc, rid_key='rid') }} + {% else %} + + {% endif %} + {% endif %} + + + + + + {% else %} + {% set pdoc = {'_id': pid, 'hidden': true, 'title': '*'} %} + + + + + + + + {% endif %} + {%- endfor -%} + +
    {{ _('Status') }}{{ _('Problem') }}{{ _('Submit') }}% {{ _('AC') }}{{ _('Difficulty') }}
    + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +
    + + + + +
    + {% endif %} + {{ problem.render_problem_title( + pdoc, + invalid=not tsdoc['enroll'], + show_tags=false + ) }} +
    {{ pdoc.nSubmit }}{{ (100 * pdoc.nAccept / pdoc.nSubmit)|round|int if pdoc.nSubmit > 0 else _('?') }}{{ pdoc['difficulty'] or _('(None)') }}
    + + {{ problem.render_problem_title(pdoc, invalid=true) }} + ***
    +
    +
    +
    + {%- endfor -%} +
     
    +
    +
    +
    +
    +
    + +
    +
    +
    + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +
    {{ _('Status') }}
    {% if tsdoc['enroll'] %}{{ _('Completed' if tsdoc['done'] else 'In Progress') }}{% else %}{{ _('Not Enrolled') }}{% endif %}
    + {% endif %} + {% if tsdoc['enroll'] %} +
    {{ _('Progress') }}
    {{ _('Completed') }} {{ (100 * tsdoc['donePids']|length / pids|length)|round|int }}%
    + {% endif %} +
    {{ _('Enrollees') }}
    {{ tdoc['enroll']|default(0) }}
    +
    {{ _('Created By') }}
    +
    {{ user.render_inline(owner, badge=false) }}
    +
    +
    +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/training_edit.html b/packages/ui-default/templates/training_edit.html new file mode 100644 index 000000000..01425a479 --- /dev/null +++ b/packages/ui-default/templates/training_edit.html @@ -0,0 +1,63 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + + {% if page_name == 'training_edit' %} + + {% if tdoc.owner == handler.user._id or handler.user.hasPerm(perm.PERM_EDIT_TRAINING) %} + + {% endif %} + {% else %} + + {% endif %} + +
    +
    +
    +
    +
    +
    + {% include 'components/md_hint.html' %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/training_main.html b/packages/ui-default/templates/training_main.html new file mode 100644 index 000000000..f0b7fa28a --- /dev/null +++ b/packages/ui-default/templates/training_main.html @@ -0,0 +1,98 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    {{ _('All Training Plans') }}

    +
    + {% if not tdocs.length %} + {{ nothing.render('Sorry, there is no training plan.') }} + {% else %} +
      + {%- for tdoc in tdocs -%} +
    1. +
      +
      +
      +
      {{ tdoc.enroll|default(0) }}
      +
      {{ _('Enrolled') }}
      +
      +
      +
      +

      {{ tdoc.title }}

      +
      +

      {{ tdoc['content'] }}

      +
      +
        +
      • + {{ _('{0} sections').format(tdoc['dag']|length) }}, {{ _('{0} problems').format(model.training.getPids(tdoc.dag)|length) }} +
      • +
      • + {% if tsdict[tdoc.docId]['enroll'] %} + {% if not tsdict[tdoc.docId]['done'] %} + + {{ _('Completed') }} {{ (100 * tsdict[tdoc.docId]['donePids']|length / model.training.getPids(tdoc.dag)|length)|round|int }}% + {% else %} + + {{ _('Completed') }} 100% + {% endif %} + {% elif handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + {{ _('Not Enrolled') }} + {% endif %} +
      • +
      +
      +
      +
    2. + {%- endfor -%} +
    + {{ paginator.render(page, tpcount, add_qs=qs) }} + {% endif %} +
    +
    +
    + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +
    +
    +

    {{ _('Enrolled') }}

    +
    +
    +
      + {%- for tsdoc in tsdict -%} + {% if tsdoc['enroll'] %} +
    1. +
      + +
      +
      +

      {{ tdict[tsdoc['docId']]['title'] }}

      +
      {{ _('Complete') }} {{ (100 * tsdoc['donePids']|length / handler.getPids(tdict[tsdoc.docId].dag)|length)|round|int }}%
      +
      +
    2. + {% endif %} + {%- endfor -%} +
    +
    +
    + {% endif %} + {% if handler.user.hasPerm(perm.PERM_CREATE_TRAINING) %} +
    +
    +

    {{ _('Create Training Plan') }}

    +
    + +
    +
    +

    {{ _('You can create your own training plans and share with others.') }}

    +
    +
    +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_changemail_mail.html b/packages/ui-default/templates/user_changemail_mail.html new file mode 100644 index 000000000..a1d81946b --- /dev/null +++ b/packages/ui-default/templates/user_changemail_mail.html @@ -0,0 +1,6 @@ +{% extends "layout/mail.html" %} +{% block title %}{{ _('Change Email') }}{% endblock %} +{% block content %} +

    {{ _('Hello, {0}! You can click following link to active your new email of your Hydro account:').format(uname) }}

    +

    {{ url_prefix }}{{ url }}

    +{% endblock %} diff --git a/packages/ui-default/templates/user_changemail_mail_sent.html b/packages/ui-default/templates/user_changemail_mail_sent.html new file mode 100644 index 000000000..2475cf394 --- /dev/null +++ b/packages/ui-default/templates/user_changemail_mail_sent.html @@ -0,0 +1,9 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +

    {{ _('New Email') }}

    +
    + {{ _('Confirmation mail has been sent to your new email.') }} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_delete_pending.html b/packages/ui-default/templates/user_delete_pending.html new file mode 100644 index 000000000..c76e9385b --- /dev/null +++ b/packages/ui-default/templates/user_delete_pending.html @@ -0,0 +1,10 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +

    {{ _('Done') }}

    +
    + {{ _('Your account will be deleted in 7 days.') }} + {{ _('You can cancel this at user setting page.') }} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_detail.html b/packages/ui-default/templates/user_detail.html new file mode 100644 index 000000000..f37cc33d7 --- /dev/null +++ b/packages/ui-default/templates/user_detail.html @@ -0,0 +1,187 @@ +{% import "components/home.html" as home with context %} +{% extends "layout/basic.html" %} +{% block content %} + +
    +
    +
    + +
    +
    +
      +
    • +

      {{ _('Bio') }}

      +
      + {% if not udoc.bio %} + {{ nothing.render("This person is lazy and didn't wrote anything.") }} + {% else %} +
      + {{ udoc.bio|default('')|markdown|safe }} +
      + {% endif %} +
      +
    • +
    • +

      {{ _('Contributions') }} ({{ pcount }})

      +
      + {% if not pdocs %} + {{ nothing.render("Oh, the user doesn't have any contributions!") }} + {% else %} +
      +

      {{ _('Contributed Problems') }}

      +
      +
      + {%- for pdoc in pdocs -%} +

      {{ pdoc['title'] }}

      + {%- endfor -%} +
      + {% endif %} +
      +
    • +
    • +

      {{ _('Submissions') }} ({{ udoc['nSubmit']|default(0) }})

      +
      + {% if not rdocs %} + {{ nothing.render("Oh, the user hasn't submitted yet!") }} + {% else %} +
      +

      {{ _('Recent Submissions') }}

      +
      +
      + + + + + + + + + + + + + + + + + + + + + + + {%- for rdoc in rdocs -%} + {% set pdoc = pdict[rdoc.pid] %} + {% include 'record_main_tr.html' %} + {%- endfor -%} + +
      {{ _('Status') }}{{ _('Problem') }}{{ _('Submit By') }}{{ _('Time') }}{{ _('Memory') }}{{ _('Language') }}{{ _('Submit At') }}
      +
      + {% endif %} +
      +
    • +
    +
    +
    +
    +
    +
    + {% if isSelfProfile %} + {{ home.render_sidebar() }} + {% endif %} +
    +
    + +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_login.html b/packages/ui-default/templates/user_login.html new file mode 100644 index 000000000..18dd4675d --- /dev/null +++ b/packages/ui-default/templates/user_login.html @@ -0,0 +1,45 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +
    +

    {{ _('Login') }}

    +
    + {% if model.system.get('server.login') %} +
    + +
    +
    + +
    +
    + +
    +
    +
    + + +
    +
    + {% endif %} + {%- for v in handler.loginMethods -%} + + {%- endfor -%} + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_logout.html b/packages/ui-default/templates/user_logout.html new file mode 100644 index 000000000..8fcfb1834 --- /dev/null +++ b/packages/ui-default/templates/user_logout.html @@ -0,0 +1,14 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +
    +

    {{ _('Logout') }}

    +
    + +
    + +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_lostpass.html b/packages/ui-default/templates/user_lostpass.html new file mode 100644 index 000000000..3117a63e5 --- /dev/null +++ b/packages/ui-default/templates/user_lostpass.html @@ -0,0 +1,22 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +
    +

    {{ _('Lost Password') }}

    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_lostpass_mail.html b/packages/ui-default/templates/user_lostpass_mail.html new file mode 100644 index 000000000..723d72434 --- /dev/null +++ b/packages/ui-default/templates/user_lostpass_mail.html @@ -0,0 +1,6 @@ +{% extends "layout/mail.html" %} +{% block title %}{{ _('Lost Password') }}{% endblock %} +{% block content %} +

    {{ _('Hello, {0}! You can click following link to reset the password of your Hydro account:').format(uname) }}

    +

    {{ url_prefix }}{{ url }}

    +{% endblock %} diff --git a/packages/ui-default/templates/user_lostpass_mail_sent.html b/packages/ui-default/templates/user_lostpass_mail_sent.html new file mode 100644 index 000000000..bbeeb936e --- /dev/null +++ b/packages/ui-default/templates/user_lostpass_mail_sent.html @@ -0,0 +1,9 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +

    {{ _('Lost Password') }}

    +
    + {{ _('Password reset mail has been sent to your email.') }} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_lostpass_with_code.html b/packages/ui-default/templates/user_lostpass_with_code.html new file mode 100644 index 000000000..ebb6ee07d --- /dev/null +++ b/packages/ui-default/templates/user_lostpass_with_code.html @@ -0,0 +1,30 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +

    {{ _('Lost Password') }}

    +
    +
    + +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_register.html b/packages/ui-default/templates/user_register.html new file mode 100644 index 000000000..f7a66b9e6 --- /dev/null +++ b/packages/ui-default/templates/user_register.html @@ -0,0 +1,23 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +
    +

    {{ _('Sign Up') }}

    +
    +
    + +
    +
    +
    + {{ captcha|safe }} + + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_register_mail.html b/packages/ui-default/templates/user_register_mail.html new file mode 100644 index 000000000..aa59f2af2 --- /dev/null +++ b/packages/ui-default/templates/user_register_mail.html @@ -0,0 +1,6 @@ +{% extends "layout/mail.html" %} +{% block title %}{{ _('Sign Up') }}{% endblock %} +{% block content %} +

    {{ _('Hello! You can click following link to sign up your Hydro account:') }}

    +

    {{ url_prefix|safe }}{{ path|safe }}

    +{% endblock %} diff --git a/packages/ui-default/templates/user_register_mail_sent.html b/packages/ui-default/templates/user_register_mail_sent.html new file mode 100644 index 000000000..1167c7c8f --- /dev/null +++ b/packages/ui-default/templates/user_register_mail_sent.html @@ -0,0 +1,9 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +

    {{ _('Sign Up') }}

    +
    + {{ _('Sign up mail has been sent to your email.') }} +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/user_register_with_code.html b/packages/ui-default/templates/user_register_with_code.html new file mode 100644 index 000000000..751f2e242 --- /dev/null +++ b/packages/ui-default/templates/user_register_with_code.html @@ -0,0 +1,36 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
    +

    {{ _('Sign Up') }}

    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    +{% endblock %} diff --git a/packages/ui-default/templates/wiki_help.html b/packages/ui-default/templates/wiki_help.html new file mode 100644 index 000000000..1f2f744e7 --- /dev/null +++ b/packages/ui-default/templates/wiki_help.html @@ -0,0 +1,164 @@ +{% extends "layout/wiki_base.html" %} +{% block wiki_content %} +
    +
    +

    {{ _('Domain') }}

    +
    +
    +

    每个用户可以创建自己的域。比如,老师可以为课程创建一个域,然后把题目和学生加入到此域中。再如,可以把域作为团队功能。

    +

    通过合理的设置角色及其权限,可以把域设为公开或私有。

    +

    默认域为 system,用户直接访问 Hydro 域名就会访问此域,点击 Hydro Logo 会从任意域跳转回 system 域。

    +

    每个域的题库、讨论、训练以及比赛是独立且自治的。

    +
    +
    +
    +
    +

    {{ _("Compilers' Version and Parameters") }}

    +
    +
    +

    Hydro 使用HydroJudge进行评测,以docker镜像的形式安装至评测机。所使用的编译器均为创建镜像时debian testing分支的最新版。编译参数如下:

    + {% if languages %} +
      + {%- for k, v in languages -%} +
    • {{ k }}
      {{ v }}
    • + {%- endfor -%} +
    + {% endif %} +
    +
    +
    +
    +

    {{ _('Limitations') }}

    +
    +
    +

    Hydro 评测机使用进程的CPU时间计算时间消耗,时间的限定为题目中评测点所指定的时间。

    +

    Hydro 评测机使用进程虚拟内存与物理内存总和计算内存空间消耗。内存空间默认限定为256MiB,题目中特别指明的,限定为题目中评测点所指定的内存空间。

    +
    +
    +
    +
    +

    {{ _('IO') }}

    +
    +
    +

    若无特殊说明,Hydro 使用标准输入输出(控制台输入输出,屏幕输入输出,STD I/O)。

    +
    +
    +
    +
    +

    {{ _('Judge Status') }}

    +
    +
    +
      +
    • Waiting 评测:评测请求正在等待被评测机抓取
    • +
    • Fetched 评测:评测请求已被评测机抓取,正在准备开始评测
    • +
    • Compiling 评测:正在编译中
    • +
    • Judging 评测:编译成功,正在评测中
    • +
    • Accepted 通过:程序输出完全正确
    • +
    • Wrong Answer 不通过:程序输出与标准答案不一致(不包括行末空格以及文件末空行)
    • +
    • Time Exceeded 不通过:程序运行时间超过了题目限制
    • +
    • Memory Exceeded 不通过:程序运行内存空间超过了题目限制
    • +
    • Runtime Error 不通过:程序运行时错误(如数组越界、被零除、运算溢出、栈溢出、无效指针等)
    • +
    • Compile Error 不通过:编译失败
    • +
    • System Error 错误:系统错误(如果您遇到此问题,请及时在讨论区进行反馈)
    • +
    • Canceled 其他:评测被取消
    • +
    • Unknown Error 其他:未知错误
    • +
    • Ignored 其他:被忽略
    • +
    +
    有“成绩取消”字样则说明管理员手动标记此记录为取消,可能违反了服务条款,比如代码被发现与其他用户的代码十分相似。
    +
    +
    +
    +
    +

    {{ _('Compile Error') }}

    +
    +
    +

    可能有以下情况:

    +
      +
    • 1. 递交时选错了编程语言
    • +
    • 2. Java 的主类名没有使用 "Main"
    • +
    • 3. 对于 C/C++:见下
    • +
    • 4. 一般性的编译错误
    • +
    + +
    +

    对 C/C++ 选手的特别提醒:

    +
      +
    • 1. __int64 在 GNU C++ 中应写成 long long 类型
    • +
    • 2. main() 返回值必须定义为 int ,而不是 void
    • +
    • 3. for 语句中的指标变量 i 将会在如"for (int i = 0...) {...}"语句之后变为无效
    • +
    • 4. itoa 不是一个通用 ANSI 函数(标准 C/C++ 中无此函数)
    • +
    • 5. printf 中使用 %lf 格式是不正确的
    • +
    +
    +
    +
    +
    +
    +

    {{ _('Training') }}

    +
    +
    +

    我们精心挑选了一些题目组成了训练计划。单击导航栏的“训练”即可进入!

    +
    +
    +
    +
    +

    {{ _('Contest') }}

    +
    +
    +

    按照赛制不同,有不同的递交、排名规则。 +

    OI 赛制所有题目均以最后一次递交为准,特别地,请避免编译错误。

    +

    OI 赛制排名规则为:总分高的排在前面,总分相等则排名相同。

    +

    ACM/ICPC 赛制所有题目递交后立即评测,以是否通过为准。

    +

    ACM/ICPC 赛制排名规则为:通过题目数多的排在前面,通过题目数相同的做题耗时(含罚时)少的排在前。

    +

    时间与空间限制以题目说明为准,默认限制参见{{ _('Limitations') }}。

    +
    +
    +
    +
    +

    {{ _('Accepted Ratio') }}

    +
    +
    +

    通过率的影响极其恶劣,Hydro 不提供也不承认通过率。

    +
    +
    +
    +
    +

    {{ _('RP Algorithm') }}

    +
    +
    +

    RP 可由题目,活动,比赛等获得。

    +
    +
    +
    +
    +

    {{ _('Difficulty Algorithm') }}

    +
    +
    +

    Hydro 中题目的难度,根据递交数、通过率以及每个递交的递交时间和评测结果,通过算法计算得出。

    +

    因此,请注意以下几点:

    +
      +
    • 0. 一般地,难度的数值越大,该题目越难。
    • +
    • 1. 新题目的难度可能不准确;在题目获得大量递交之后,难度才会变得较为准确。
    • +
    • 2. 越早递交评测的用户代码的评测结果对题目难度影响越大。
    • +
    • 3. 题目的难度由算法计算得出,有可能出现不准确的结果。
    • +
    +
    +
    +
    +
    +

    {{ _('Dataset Format') }}

    +
    +
    +

    详见配置文档

    +
    +
    +
    +
    +

    {{ _('Forgot Password and/or Username') }}

    +
    +
    +

    如果您无法登录,请仔细想想,是不是用户名记错了。比如,自己原本想要注册的用户名已经被注册,所以使用了一个带有前和/或后缀的用户名。

    +

    如果您确信您的账号被盗或者忘记了账号和/或密码,请及时{{ _('Reset Password or Find Username') }}。

    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/theme/default.js b/packages/ui-default/theme/default.js new file mode 100644 index 000000000..741bd8536 --- /dev/null +++ b/packages/ui-default/theme/default.js @@ -0,0 +1,27 @@ +import 'normalize.css/normalize.css'; +import 'codemirror/lib/codemirror.css'; +import 'vj-simplemde/src/css/simplemde.css'; +import 'pickadate/lib/themes/classic.css'; +import 'pickadate/lib/themes/classic.date.css'; +import 'pickadate/lib/themes/classic.time.css'; +import 'katex/dist/katex.min.css'; + +import 'vj/misc/float.styl'; +import 'vj/misc/typography.styl'; +import 'vj/misc/textalign.styl'; +import 'vj/misc/grid.styl'; +import 'vj/misc/slideout.styl'; + +import 'vj/misc/.iconfont/webicon.styl'; +import 'vj/misc/immersive.styl'; +import 'vj/misc/structure.styl'; +import 'vj/misc/section.styl'; +import 'vj/misc/nothing.styl'; + +import 'vj/components/cmeditor/cmeditor.styl'; +import 'vj/components/datepicker/datepicker.styl'; +import 'vj/components/katex/katex.styl'; + +// load all page stylesheets +const pageStyleReq = require.context('../', true, /\.page\.styl$/i); +pageStyleReq.keys().map((key) => pageStyleReq(key).default); diff --git a/packages/ui-default/tool/checkTranslation.js b/packages/ui-default/tool/checkTranslation.js new file mode 100644 index 000000000..78c460486 --- /dev/null +++ b/packages/ui-default/tool/checkTranslation.js @@ -0,0 +1,106 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-unused-vars */ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable no-eval */ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +const name = process.argv[2]; + +const IGNORE_CHECK = ['en']; +const IGNORE_MISSING = [ + '?', 'AC', 'Gravatar', 'ID', 'MD5', 'URL', +]; +const RE_TEMPLATE = /_\(['"]([\s\S])*?['"]\)/gmi; +const RE_UI = /i18n\(['"]([\s\S])*?['"](,.*?)?\)/gmi; +const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates'); +const LOCALE_ROOT = path.resolve(__dirname, '..', 'locales'); +const texts = {}; +const result = {}; +const locales = fs.readdirSync(LOCALE_ROOT); +let currentFile = ''; + +function _(str, format) { + if (!texts[str]) texts[str] = [currentFile]; + else texts[str].push(currentFile); +} +const i18n = _; +const Setting = _; + +function scanTemplate(folder, relative = 'templates') { + const files = fs.readdirSync(folder); + for (const file of files) { + const p = path.join(folder, file); + if (fs.statSync(p).isDirectory()) { + scanTemplate(p, path.join(relative, file)); + } else { + currentFile = path.join(relative, file); + const f = fs.readFileSync(p).toString(); + f.replace(RE_TEMPLATE, (substr) => { + try { + // eslint-disable-next-line no-eval + eval(substr); + } catch (e) { + console.error('Cannot parse: ', substr, ' in file ', p); + } + }); + } + } +} +function scanUi(folder, relative = '') { + const files = fs.readdirSync(folder); + for (const file of files) { + const p = path.join(folder, file); + if (fs.statSync(p).isDirectory()) { + scanUi(p, path.join(relative, file)); + } else { + currentFile = path.join(relative, file); + const f = fs.readFileSync(p).toString(); + f.replace(RE_UI, (substr) => { + try { + eval(substr); + } catch (e) { + if (e.message.endsWith('is not defined')) { + global[e.message.split(' ')[0]] = () => { }; + try { + eval(substr); + } catch (err) { + console.error('Cannot parse: ', substr, ' in file ', p); + } + } else console.error('Cannot parse: ', substr, ' in file ', p); + } + }); + } + } +} +scanTemplate(TEMPLATE_ROOT); +scanUi(path.join(process.cwd(), 'components')); +scanUi(path.join(process.cwd(), 'pages')); +scanUi(path.join(process.cwd(), 'misc')); +if (!name) { + for (const locale of locales) { + if (!IGNORE_CHECK.includes(locale.split('.')[0])) { + const p = path.join(LOCALE_ROOT, locale); + const f = fs.readFileSync(p).toString(); + const l = yaml.safeLoad(f); + for (const str in texts) { + if (!l[str]) { + if (result[str]) result[str].locale.push(locale); + else result[str] = { source: texts[str], locale: [locale] }; + } + } + } + } +} else { + const p = path.join(LOCALE_ROOT, name); + const f = fs.readFileSync(p).toString(); + const l = yaml.safeLoad(f); + for (const str in texts) { + if (!l[str]) result[str] = texts[str]; + } +} +for (const str of IGNORE_MISSING) delete result[str]; +console.log(`${Object.keys(result).length} translations missing.`); +fs.writeFileSync(path.join(__dirname, '..', '__result.json'), JSON.stringify(result, null, 2)); +console.log(`Result wrote to ${path.resolve(__dirname, '..', '__result.json')}`); diff --git a/packages/ui-default/utils/delay.js b/packages/ui-default/utils/delay.js new file mode 100644 index 000000000..c70f07f3e --- /dev/null +++ b/packages/ui-default/utils/delay.js @@ -0,0 +1,5 @@ +export default function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +window.Hydro.utils.delay = delay; diff --git a/packages/ui-default/utils/emulateAnchorClick.js b/packages/ui-default/utils/emulateAnchorClick.js new file mode 100644 index 000000000..d40d94ca5 --- /dev/null +++ b/packages/ui-default/utils/emulateAnchorClick.js @@ -0,0 +1,20 @@ +/** + * @param {Event} ev + * @param {String} targetUrl + * @param {Boolean} alwaysOpenInNewWindow + */ +export default function emulateAnchorClick(ev, targetUrl, alwaysOpenInNewWindow = false) { + let openInNewWindow; + if (alwaysOpenInNewWindow) { + openInNewWindow = true; + } else { + openInNewWindow = (ev.ctrlKey || ev.shiftKey || ev.metaKey); + } + if (openInNewWindow) { + window.open(targetUrl); + } else { + window.location.href = targetUrl; + } +} + +window.Hydro.utils.emulateAnchorClick = emulateAnchorClick; diff --git a/packages/ui-default/utils/i18n.js b/packages/ui-default/utils/i18n.js new file mode 100644 index 000000000..11417aca5 --- /dev/null +++ b/packages/ui-default/utils/i18n.js @@ -0,0 +1,10 @@ +import substitute from './substitute'; + +export default function i18n(str, ...params) { + if (window.LOCALES && window.LOCALES[str]) { + return substitute(window.LOCALES[str], params); + } + return substitute(str, params); +} + +window.Hydro.utils.i18n = i18n; diff --git a/packages/ui-default/utils/loadReactRedux.js b/packages/ui-default/utils/loadReactRedux.js new file mode 100644 index 000000000..5f5de7c1f --- /dev/null +++ b/packages/ui-default/utils/loadReactRedux.js @@ -0,0 +1,32 @@ +export default async function loadReactRedux(storeReducer) { + const React = await import('react'); + const { render, unmountComponentAtNode } = await import('react-dom'); + const { Provider } = await import('react-redux'); + const { createStore, applyMiddleware } = await import('redux'); + const { default: reduxThunk } = await import('redux-thunk'); + const { default: reduxPromise } = await import('redux-promise-middleware'); + + const reduxMiddlewares = []; + reduxMiddlewares.push(reduxThunk); + reduxMiddlewares.push(reduxPromise); + + if (process.env.NODE_ENV !== 'production') { + const { createLogger } = await import('redux-logger'); + reduxMiddlewares.push(createLogger({ + collapsed: true, + duration: true, + })); + } + + const store = createStore(storeReducer, applyMiddleware(...reduxMiddlewares)); + + return { + React, + render, + unmountComponentAtNode, + Provider, + store, + }; +} + +window.Hydro.utils.loadReactRedux = loadReactRedux; diff --git a/packages/ui-default/utils/mediaQuery.js b/packages/ui-default/utils/mediaQuery.js new file mode 100644 index 000000000..9ebdcfaeb --- /dev/null +++ b/packages/ui-default/utils/mediaQuery.js @@ -0,0 +1,16 @@ +export function isAbove(width) { + if (window.matchMedia) { + return window.matchMedia(`(min-width: ${width}px)`).matches; + } + return window.innerWidth >= width; +} + +export function isBelow(width) { + if (window.matchMedia) { + return window.matchMedia(`(max-width: ${width}px)`).matches; + } + return window.innerWidth <= width; +} + +window.Hydro.utils.isAbove = isAbove; +window.Hydro.utils.isBelow = isBelow; diff --git a/packages/ui-default/utils/mongoId.js b/packages/ui-default/utils/mongoId.js new file mode 100644 index 000000000..508cfd5e5 --- /dev/null +++ b/packages/ui-default/utils/mongoId.js @@ -0,0 +1,16 @@ +/* eslint-disable */ +// https://github.com/andrasq/node-mongoid-js/blob/master/mongoid.js + +export function parse(idstring) { + if (typeof idstring !== 'string') { + idstring = String(idstring); + } + return { + timestamp: parseInt(idstring.slice( 0, 0+8), 16), + machineid: parseInt(idstring.slice( 8, 8+6), 16), + pid: parseInt(idstring.slice(14, 14+4), 16), + sequence: parseInt(idstring.slice(18, 18+6), 16), + }; +} + +window.Hydro.utils.mongoId = parse; diff --git a/packages/ui-default/utils/parseQueryString.js b/packages/ui-default/utils/parseQueryString.js new file mode 100644 index 000000000..5b6859e18 --- /dev/null +++ b/packages/ui-default/utils/parseQueryString.js @@ -0,0 +1,13 @@ +export default function parseQueryString(str) { + const obj = {}; + (str || document.location.search) + .replace(/(^\?)/, '') + .split('&') + .forEach((n) => { + const [key, value] = n.split('=').map((v) => decodeURIComponent(v)); + obj[key] = value; + }); + return obj; +} + +window.Hydro.utils.parseQueryString = parseQueryString; diff --git a/packages/ui-default/utils/pipeStream.js b/packages/ui-default/utils/pipeStream.js new file mode 100644 index 000000000..8c75698bf --- /dev/null +++ b/packages/ui-default/utils/pipeStream.js @@ -0,0 +1,21 @@ +export default async function pipeStream(read, write, abort) { + if (window.WritableStream && read.pipeTo) { + const abortController = new AbortController(); + if (abort) abort.abort = abortController.abort.bind(abortController); + await read.pipeTo(write, abortController); + } else { + const writer = write.getWriter(); + if (abort) abort.abort = writer.abort.bind(writer); + const reader = read.getReader(); + // eslint-disable-next-line no-constant-condition + while (1) { + const readResult = await reader.read(); + if (readResult.done) { + writer.close(); + break; + } else writer.write(readResult.value); + } + } +} + +window.Hydro.utils.pipeStream = pipeStream; diff --git a/packages/ui-default/utils/pjax.js b/packages/ui-default/utils/pjax.js new file mode 100644 index 000000000..bef2999f8 --- /dev/null +++ b/packages/ui-default/utils/pjax.js @@ -0,0 +1,150 @@ +// Modified from jquery-pjax to support multiple fragments and jQuery 3.0 +// https://github.com/defunkt/jquery-pjax/blob/master/jquery.pjax.js + +import { v4 as uuid } from 'uuid'; +import Notification from 'vj/components/notification'; +import NProgress from 'vj/components/nprogress'; +import request from './request'; + +const pjax = {}; + +let currentState = null; +let currentXHR = null; +let inProgress = 0; + +function cancelLastXHR() { + if (currentXHR && currentXHR.readyState < 4) { + currentXHR.abort(); + currentXHR = null; + } +} + +function canonicalizeUrl(url) { + const a = document.createElement('a'); + a.href = url; + return a.href; +} + +function extractMetaData(requestOption, response) { + const metaData = { + title: response.title ? response.title : document.title, + url: response.url ? response.url : requestOption.url, + }; + metaData.url = canonicalizeUrl(metaData.url); + return metaData; +} + +function incProgress() { + inProgress++; + NProgress.done(); + NProgress.start(); +} + +function decProgress() { + inProgress--; + if (inProgress === 0) { + NProgress.done(); + } +} + +pjax.request = async (opt) => { + const options = { + method: 'get', + push: true, + ...opt, + }; + if (!currentState) { + // create initial state + currentState = { + id: uuid(), + options: { + url: window.location.href, + }, + }; + window.history.replaceState(currentState, null); + } + cancelLastXHR(); + if (options.push) { + // in popstate, options.push === false + window.history.pushState(null, null, options.url); + } + incProgress(); + try { + const data = await request.ajax({ + beforeSend(xhr) { + currentXHR = xhr; + }, + ...options, + }); + const meta = extractMetaData(options, data); + currentState = { + id: uuid(), + options: opt, + }; + if (options.push) { + window.history.replaceState(currentState, null, meta.url); + } + document.title = meta.title; + data.fragments.forEach((fragment) => { + if (process.env.NODE_ENV !== 'production') { + if (fragment.html === undefined) { + // eslint-disable-next-line quotes + throw new Error(`Fragement should contain 'html'`); + } + } + const $el = $(fragment.html.trim()); + if (process.env.NODE_ENV !== 'production') { + if ($el.length === 0) { + // eslint-disable-next-line quotes + throw new Error(`Unable to build elements from fragment 'html'`); + } + } + const fragmentId = $el.attr('data-fragment-id'); + if (process.env.NODE_ENV !== 'production') { + if (!fragmentId) { + // eslint-disable-next-line quotes + throw new Error(`Unable to extract fragment id from fragment 'html'`); + } + } + const $target = $(`[data-fragment-id="${fragmentId}"]`); + if (process.env.NODE_ENV !== 'production') { + if ($target.length === 0) { + // eslint-disable-next-line quotes + throw new Error(`Unable to get target fragment from fragment id`); + } + } + $target.trigger('vjContentRemove'); + $target.replaceWith($el); + $el.trigger('vjContentNew'); + }); + } catch (err) { + if (!err.aborted) { + Notification.error(err.message); + console.log(err); + } + } finally { + decProgress(); + } +}; + +function handlePopState(ev) { + if (!ev.originalEvent) { + return; + } + const { state } = ev.originalEvent; + if (!state) { + return; + } + if (!state.id || (currentState && state.id === currentState.id)) { + return; + } + pjax.request({ + ...state.options, + push: false, + }); +} + +$(window).on('popstate', handlePopState); + +export default pjax; +window.Hydro.utils.pjax = pjax; diff --git a/packages/ui-default/utils/request.js b/packages/ui-default/utils/request.js new file mode 100644 index 000000000..a42865fcc --- /dev/null +++ b/packages/ui-default/utils/request.js @@ -0,0 +1,95 @@ +import i18n from './i18n'; + +const request = { + /** + * @param {object} options + */ + async ajax(options) { + return new Promise((resolve, reject) => { + $ + .ajax({ + dataType: 'json', + ...options, + }) + .fail((jqXHR, textStatus, errorThrown) => { + if (textStatus === 'abort') { + const err = new Error(i18n('Aborted')); + err.aborted = true; + reject(err); + } else if (jqXHR.readyState === 0) { + reject(new Error(i18n('Network error'))); + } else if (errorThrown instanceof Error) { + reject(errorThrown); + } else if (typeof jqXHR.responseJSON === 'object' && jqXHR.responseJSON.error) { + reject(new Error(jqXHR.responseJSON.error.message)); + } else { + reject(new Error(textStatus)); + } + }) + .done(resolve); + }); + }, + + /** + * @param {string} url + * @param {FormData} FormData + */ + postFile(url, form) { + return this.ajax({ + url, + data: form, + processData: false, + contentType: false, + type: 'POST', + dataType: undefined, + }); + }, + + /** + * @param {string} url + * @param {JQueryStatic | Node | string | object} dataOrForm + * @param {object} options + */ + post(url, dataOrForm = {}, options = {}) { + let postData; + if (dataOrForm instanceof jQuery && dataOrForm.is('form')) { + // $form + postData = dataOrForm.serialize(); + } else if (dataOrForm instanceof Node && $(dataOrForm).is('form')) { + // form + postData = $(dataOrForm).serialize(); + } else if (typeof dataOrForm === 'string') { + // foo=bar&box=boz + postData = dataOrForm; + } else { + // {foo: 'bar'} + postData = $.param({ + csrfToken: UiContext.csrfToken, + ...dataOrForm, + }, true); + } + return request.ajax({ + url, + method: 'post', + data: postData, + ...options, + }); + }, + + /** + * @param {string} url + * @param {object} qs + * @param {object} options + */ + get(url, qs = {}, options = {}) { + return request.ajax({ + url, + data: qs, + method: 'get', + ...options, + }); + }, +}; + +export default request; +window.Hydro.utils.request = request; diff --git a/packages/ui-default/utils/slide.js b/packages/ui-default/utils/slide.js new file mode 100644 index 000000000..9a24b973e --- /dev/null +++ b/packages/ui-default/utils/slide.js @@ -0,0 +1,59 @@ +import 'jquery.easing'; + +export async function slideDown($element, duration, fromCss = {}, toCss = {}) { + const originalStyl = $element.attr('style') || ''; + $element.css({ + position: 'absolute', + visibility: 'none', + display: 'block', + }); + const height = $element.outerHeight(); + $element.attr('style', originalStyl); + $element.css({ + height: 0, + overflow: 'hidden', + display: 'block', + ...fromCss, + }); + $element.height(); + await $element + .transition({ + height, + ...toCss, + }, { + duration, + easing: 'easeOutCubic', + }) + .promise(); + $element.attr('style', originalStyl); + $element.css({ + display: 'block', + }); +} + +export async function slideUp($element, duration, fromCss = {}, toCss = {}) { + const originalStyl = $element.attr('style') || ''; + const height = $element.outerHeight(); + $element.css({ + height, + overflow: 'hidden', + display: 'block', + ...fromCss, + }); + $element.height(); + await $element + .transition({ + height: 0, + ...toCss, + }, { + duration, + easing: 'easeOutCubic', + }) + .promise(); + $element.attr('style', originalStyl); + $element.css({ + display: 'none', + }); +} + +window.Hydro.utils.slide = { slideUp, slideDown }; diff --git a/packages/ui-default/utils/substitute.js b/packages/ui-default/utils/substitute.js new file mode 100644 index 000000000..739720252 --- /dev/null +++ b/packages/ui-default/utils/substitute.js @@ -0,0 +1,10 @@ +export default function substitute(str, obj) { + return str.replace(/\{([^{}]+)\}/g, (match, key) => { + if (obj[key] !== undefined) { + return String(obj[key]); + } + return `{${key}}`; + }); +} + +window.Hydro.utils.substitute = substitute; diff --git a/packages/ui-default/utils/tpl.js b/packages/ui-default/utils/tpl.js new file mode 100644 index 000000000..286dc4449 --- /dev/null +++ b/packages/ui-default/utils/tpl.js @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +export default function tpl(pieces, ...substitutions) { + let result = pieces[0]; + for (let i = 0; i < substitutions.length; ++i) { + const subst = substitutions[i]; + let substHtml; + if (typeof subst === 'object' && subst.templateRaw) { + substHtml = subst.html; + } else substHtml = _.escape(String(subst)); + result += substHtml + pieces[i + 1]; + } + return result; +} + +export function rawHtml(html) { + return { + templateRaw: true, + html, + }; +} + +global.Hydro.utils.tpl = tpl; diff --git a/packages/ui-default/utils/zIndexManager.js b/packages/ui-default/utils/zIndexManager.js new file mode 100644 index 000000000..344271382 --- /dev/null +++ b/packages/ui-default/utils/zIndexManager.js @@ -0,0 +1,13 @@ +let zIndexCurrent = 1000; + +const manager = { + getCurrent() { + return zIndexCurrent; + }, + getNext() { + return ++zIndexCurrent; + }, +}; + +export default manager; +window.Hydro.utils.zIndexManager = manager; diff --git a/packages/ui-default/utils/zip.js b/packages/ui-default/utils/zip.js new file mode 100644 index 000000000..266006114 --- /dev/null +++ b/packages/ui-default/utils/zip.js @@ -0,0 +1,9 @@ +import 'streamsaver/examples/zip-stream'; + +export const createZipStream = window.ZIP; + +export async function createZipBlob(underlyingSource) { + return new Response(createZipStream(underlyingSource)).blob(); +} + +window.Hydro.utils.zip = { createZipStream, createZipBlob };