diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js new file mode 100644 index 000000000..90f69915f --- /dev/null +++ b/ui/.eslintrc.js @@ -0,0 +1,94 @@ +const path = require('path'); + +module.exports = { + "parser": "babel-eslint", + "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 + } + }, + "settings": { + "import/resolver": { + "webpack": { + "config": { + "resolve": { + "alias": { + "vj": path.resolve(__dirname), + } + } + } + } + } + }, + "globals": { + "__webpack_public_path__": true, + "__webpack_require__": true, + "UiContext": true, + "UserContext": true, + "Context": true, + "LOCALES": true + }, + "rules": { + "comma-dangle": [ + "error", + "always-multiline" + ], + "indent": [ + "error", + 2, + { + "SwitchCase": 0 + } + ], + "max-len": [ + "error", + 150 + ], + "quotes": "warn", + "func-names": "off", + "class-methods-use-this": "off", + "consistent-return": "warn", + "no-restricted-syntax": "warn", + "no-unused-vars": "warn", + "no-console": "off", + "no-continue": "off", + "no-mixed-operators": "off", + "no-plusplus": "off", + "no-underscore-dangle": "off", + "no-await-in-loop": "off", + "no-lonely-if": "off", + "import/first": "off", + "react/prefer-stateless-function": "off", + "react/self-closing-comp": "off", + "react/prop-types": "off", + "react/jsx-filename-extension": "off", + "react/jsx-one-expression-per-line": "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/ui/Entry.js b/ui/Entry.js new file mode 100644 index 000000000..8124d37cb --- /dev/null +++ b/ui/Entry.js @@ -0,0 +1,93 @@ +import 'jquery.transit'; + +import 'normalize.css/normalize.css'; +import 'codemirror/lib/codemirror.css'; + +import _ from 'lodash'; + +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 { PageLoader } from 'vj/misc/PageLoader'; +import delay from 'vj/utils/delay'; + +// eslint-disable-next-line camelcase +__webpack_public_path__ = UiContext.cdn_prefix; + +const pageLoader = new PageLoader(); + +const currentPage = pageLoader.getNamedPage(document.documentElement.getAttribute('data-page')); +const includedPages = pageLoader.getAutoloadPages(); + +function buildSequence(pages, type) { + if (process.env.NODE_ENV !== 'production') { + if (['before', 'after'].indexOf(type) === -1) { + // eslint-disable-next-line quotes + throw new Error(`'type' should be one of 'before' or 'after'`); + } + } + return pages + .filter(p => p && p[`${type}Loading`]) + .map(p => ({ + page: p, + func: p[`${type}Loading`], + type, + })); +} + +async function load() { + const loadSequence = [ + ...buildSequence(includedPages, 'before'), + ...buildSequence([currentPage], 'before'), + ...buildSequence(includedPages, 'after'), + ...buildSequence([currentPage], 'after'), + ]; + // eslint-disable-next-line no-restricted-syntax + for (const { page, func, type } of loadSequence) { + if (typeof func !== 'function') { + if (process.env.NODE_ENV !== 'production') { + throw new Error(`The '${type}Loading' function of '${page.name}' is not callable`); + } + continue; + } + if (process.env.NODE_ENV !== 'production') { + console.time(`${page.name}: ${type}Loading`); + } + try { + await func(); + } catch (e) { + console.error(`Failed to call '${type}Loading' of ${page.name}\n${e.stack}`); + } + if (process.env.NODE_ENV !== 'production') { + console.timeEnd(`${page.name}: ${type}Loading`); + } + } + const sections = _.map($('.section').get(), (section, idx) => ({ + shouldDelay: idx < 5, // only animate first 5 sections + $element: $(section), + })); + // eslint-disable-next-line no-restricted-syntax + for (const { $element, shouldDelay } of sections) { + $element.addClass('visible'); + if (shouldDelay) { + await delay(50); + } + } + await delay(500); + // eslint-disable-next-line no-restricted-syntax + for (const { $element } of sections) { + $element.trigger('vjLayout'); + } + $(document).trigger('vjPageFullyInitialized'); +} + +load(); diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..069c0a4cf --- /dev/null +++ b/ui/README.md @@ -0,0 +1,415 @@ +# Vijos UI Framework + +## Responsive Cutoffs + +Vijos use the following cutoffs to determine the size of a device. + +Small: `[0 - 450px)` (mobile) + +Medium: `[450px, 1000px)` (tablet) + +Large: `[1000px, -)` (desktop) + +## Layout + +### Grid + +See [Foundation Grid](http://foundation.zurb.com/sites/docs/grid.html) + +### Float + +See [Foundation Float](http://foundation.zurb.com/sites/docs/float-classes.html) + +> NOTE: `.float-center` is not implemented. + +## Typography + +HTML elements such as headings (`h1`, `h2`, ...), lists (`ol`, `ul`, `li`, ...) and tables does not have margins and paddings by default in order to easy the styling of semantic structures. However you may want suitable space between those elements when they are used to present content to users (for example, problem content). In this case, you need to enable the typography styling by wrapping them with `
`: + +```html +
+

Notice

+

The content will be well formatted.

+ +
+``` + +### Text Alignment + +```html + + + + +``` + +## Prototype Components + +### Media Object + +TODO + +### Number Box Object + +TODO + +### Balancer Object + +TODO + +## Basic Components + +### Section + +Section is served as an entry to more detailed contents. By default, each section has a white background and drop-shadow. + +A section should contain a section-header and one or more section bodies. + +```html +
+
+ +
+
+ +
+
+ +
+
+``` + +There will be vertical margins between section bodies. + +#### Side Section + +If the section is acted as a sidebar section, it should contain `side` class so that its default font size becomes smaller: + +```html +
+``` + +#### Title + +You can use `` to specify the title for a section (`X` is from `h1` to `h3`). The font size and the color of the section title is different from normal headings. + +```html +
+
+

Section title

+
+
+``` + +#### Table + +If the main content of the section is a data table (or additionally with a title and a paginator), it is suggested to apply `data-table` to the table. Table should be put inside a section-body with `no-padding` decoration. + +3 * 2 table sample: + +```html +
+ +
+ + + + + + + + + + + + + + + + +
1,11,2
2,12,2
3,13,2
+
+
+``` + +> If you want to specify the width of a column, you should add `col--xxx` to the class name list (as shown above) and specify its width in a CSS rule like: +> +> ```css +> .col--1 { width: 120px; } +> ``` + +#### Samples + +Section with content: + +```html +
+
+ Section content +
+
+``` + +Section with title and content: + +```html +
+
+

Section title

+
+
+ Section content +
+
+``` + +Section with title and table content: + +```html +
+
+

Section title

+
+
+ + +
+
+
+``` + +### Button + +```html + +``` + +Additional class names: + +`rounded`: The button would have round corners. + +`primary`: The button would have blue background. + +`expanded`: The button would occupy full width of the parent. + +`inverse`: TODO + +### Input + +```html + +``` + +`X` could be `input` or `textarea`. + +Additional class names: + +`material`: TODO + +`inverse`: TODO + +### Paginator + +TODO + +### Menu + +```html + +``` + +Optionally, a menu item can be `active` or `highlight` (notice that the decorator class name is added to `menu__link` instead of `menu__item`): + +```html + + +``` + +Menu can be put inside a section. It should be placed inside `section__body no-padding` or directly inside `section`: + +```html +
+
+ +
+
+``` + +#### Menu Item from Headings + +Menu items can be automatically generated from headings. To enable this feature, you need to specify the region to extract headings and the destination of extracted menu items by setting `data-heading-extract-to` attribute: + +```html +
+

Heading 1

+

Heading 2

+
+``` + +This attribute accepts a CSS selector and all elements that contain `data-heading` attribute in the element will be extracted. `id` is essential and it will be appended to the hash part so that the page will navigate when user clicks the newly generate menu item. + +The CSS selector should match a `menu__item` element. Headings will become the sub-menu of that element. + +### Sticky + +Sticky elements are always visible when the user scrolls. To enable sticky, add `data-sticky` attribute to the element. + +Optionally, the value of `data-sticky` can be `medium` or `large`. When `data-sticky` is `medium`, the element will be sticky only when window size is larger than mobile cutoff (can be tablet or desktop). When `data-sticky` is `large`, it will take effect only when window size is larger than desktop cutoff. See section Responsive Cutoffs for actual sizes. + +The sticky element is restricted to the boundary of its closest parent. You can change this behavior by assigning attribute `data-sticky-parent` to the desired parent. `data-sticky-parent` can not be used in nested sticky elements. + +A sample of sticky sidebar: + +```html +
+
+ +
+
+
+
Sticky!
+
+
+
+``` + +- Because the columns will be stacked when window size is smaller than `large` (tablet and mobile) and we don't want sidebars keep sticky in such cases, we have `data-sticky="large"` so that the element will be sticky ONLY when window size is `large`. + +- The height of the two columns are not equal so the sticky parent should be `.row` instead. + +### CommonMark Editor + +CommonMark editor can be automatically transformed from a normal textarea. Inputs in the CommonMark editor will be synced to the textarea in real-time. To enable this feature, add `data-markdown` to the `textarea` element. + +Sample: + +```html + +``` + +> To make it work with newly added textareas, you need to trigger `vjContentNew` event for the new element. Textarea with `data-markdown` should be a children of the trigger target. + + +### Emoji + +[Emoji code](http://www.webpagefx.com/tools/emoji-cheat-sheet/) can be automatically transformed into emoji images. To enable this feature, add `data-emoji-enabled` to the element. + +Sample: + +```html +
+``` + +> To make it work with newly added contents, you need to trigger `vjContentNew` event for the new element. + +### Dropdown + +TODO + +### Navigation + +TODO + +### Star + +TODO + +### Tab + +TODO + +### Hotkey + +#### Local Hotkey + +Local hotkeys are triggered when it is assigned to a parent of the active element in which user presses specific key. + +To enable local hotkey on an element, add `data-hotkey` attribute: + +```html + +``` + +You can specify multiple hotkeys in the attribute. + +#### Global hotkey + +Global hotkeys are triggered no matter where user presses specific key. + +To enable global hotkey on an element, add `data-global-hotkey` attribute: + +```html + +``` + +You can specify multiple hotkeys in the attribute. + +#### Hotkey Syntax + +`hotKeyDef[,hotKeyDef[,hotKeyDef...]]` + +`hotKeyDef`: `keySequence[:event]` + +`keySequence`: `key1[+key2[+key3...]]` + +When `keySequence` matches, the `event` will be triggered on the element that holds the attribute. If `event` is not given in `hotKeyDef`, it will be `click` by default. In addition, if `event` is `submit`, the event will be triggered on the closest `form` element, instead of the current element. + +Sample: + +`ctrl+f1`: Triggers click on this element when user presses `Ctrl`+`F1` or `Command`+`F1`. + +`ctrl+enter:submit`: Triggers submit on the closest `form` when user presses `Ctrl+Enter` or `Command+Enter`. + +`f1:submit,f2:client`: Triggers submit when user presses `f1` and triggers click when user presses `f2`. + +### Tooltip + +```html + +``` + +Optionally, you can specify a location by assigning `data-tooltip-pos` attribute. Available values are: + +- `"top left"` +- `"top middle"` (default) +- `"top right"` +- `"bottom left"` +- `"bottom middle"` +- `"bottom right"` + +## High-Level Components + +### Comment List + +TODO + +## Other + +### JavaScript-Responsive Visibility + +#### Hide if JavaScript is disabled + +```html + +``` + +#### Hide if JavaScript is enabled + +```html + +``` diff --git a/ui/breakpoints.json b/ui/breakpoints.json new file mode 100644 index 000000000..386c14cb0 --- /dev/null +++ b/ui/breakpoints.json @@ -0,0 +1,5 @@ +{ + "mobile": 450, + "desktop": 1000, + "hd": 1250 +} diff --git a/ui/common/color.inc.styl b/ui/common/color.inc.styl new file mode 100644 index 000000000..defc99594 --- /dev/null +++ b/ui/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/ui/common/common.inc.styl b/ui/common/common.inc.styl new file mode 100644 index 000000000..ff72825e0 --- /dev/null +++ b/ui/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/ui/common/easing.inc.styl b/ui/common/easing.inc.styl new file mode 100644 index 000000000..50713e02c --- /dev/null +++ b/ui/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/ui/common/functions.inc.styl b/ui/common/functions.inc.styl new file mode 100644 index 000000000..778ac974a --- /dev/null +++ b/ui/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/ui/common/rem.inc.styl b/ui/common/rem.inc.styl new file mode 100644 index 000000000..b80603429 --- /dev/null +++ b/ui/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/ui/common/variables.inc.styl b/ui/common/variables.inc.styl new file mode 100644 index 000000000..d2623cdb1 --- /dev/null +++ b/ui/common/variables.inc.styl @@ -0,0 +1,261 @@ +// 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, +} + +// Homework Status +$homework-status-color = { + 'not_started': $text-2-color, + 'ongoing': $success-color, +} diff --git a/ui/components/DOMAttachedObject.js b/ui/components/DOMAttachedObject.js new file mode 100644 index 000000000..1341c6c57 --- /dev/null +++ b/ui/components/DOMAttachedObject.js @@ -0,0 +1,131 @@ +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/ui/components/autocomplete/DomainSelectAutoComplete.js b/ui/components/autocomplete/DomainSelectAutoComplete.js new file mode 100644 index 000000000..3894dd924 --- /dev/null +++ b/ui/components/autocomplete/DomainSelectAutoComplete.js @@ -0,0 +1,43 @@ +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); diff --git a/ui/components/autocomplete/UserSelectAutoComplete.js b/ui/components/autocomplete/UserSelectAutoComplete.js new file mode 100644 index 000000000..080e1a731 --- /dev/null +++ b/ui/components/autocomplete/UserSelectAutoComplete.js @@ -0,0 +1,43 @@ +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); diff --git a/ui/components/autocomplete/autocomplete.page.styl b/ui/components/autocomplete/autocomplete.page.styl new file mode 100644 index 000000000..cd631147f --- /dev/null +++ b/ui/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/ui/components/autocomplete/domainselectautocomplete.page.styl b/ui/components/autocomplete/domainselectautocomplete.page.styl new file mode 100644 index 000000000..bcd2a6f24 --- /dev/null +++ b/ui/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/ui/components/autocomplete/index.js b/ui/components/autocomplete/index.js new file mode 100644 index 000000000..9fbb58c43 --- /dev/null +++ b/ui/components/autocomplete/index.js @@ -0,0 +1,202 @@ +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); diff --git a/ui/components/autocomplete/userselectautocomplete.page.styl b/ui/components/autocomplete/userselectautocomplete.page.styl new file mode 100644 index 000000000..2c0329d57 --- /dev/null +++ b/ui/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/ui/components/calendar/calendar.page.styl b/ui/components/calendar/calendar.page.styl new file mode 100644 index 000000000..7606ad085 --- /dev/null +++ b/ui/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/ui/components/calendar/index.js b/ui/components/calendar/index.js new file mode 100644 index 000000000..3cd464d1f --- /dev/null +++ b/ui/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/ui/components/cmeditor/cmeditor.page.js b/ui/components/cmeditor/cmeditor.page.js new file mode 100644 index 000000000..107411869 --- /dev/null +++ b/ui/components/cmeditor/cmeditor.page.js @@ -0,0 +1,23 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; +import delay from 'vj/utils/delay'; +import CmEditor from '.'; + +import 'vj-simplemde/src/css/simplemde.css'; +import './cmeditor.styl'; + +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/ui/components/cmeditor/cmeditor.styl b/ui/components/cmeditor/cmeditor.styl new file mode 100644 index 000000000..6c00da3f4 --- /dev/null +++ b/ui/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/ui/components/cmeditor/index.js b/ui/components/cmeditor/index.js new file mode 100644 index 000000000..bf6cea053 --- /dev/null +++ b/ui/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/ui/components/cmeditor/textareaHandler.js b/ui/components/cmeditor/textareaHandler.js new file mode 100644 index 000000000..c959c4d83 --- /dev/null +++ b/ui/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/ui/components/cmeditor/vjcmeditor.js b/ui/components/cmeditor/vjcmeditor.js new file mode 100644 index 000000000..67935e959 --- /dev/null +++ b/ui/components/cmeditor/vjcmeditor.js @@ -0,0 +1,129 @@ +import _ from 'lodash'; +import SimpleMDE from 'vj-simplemde'; + +import 'codemirror/mode/clike/clike'; +import 'codemirror/mode/pascal/pascal'; +import 'codemirror/mode/python/python'; +import 'codemirror/mode/php/php'; +import 'codemirror/mode/rust/rust'; +import 'codemirror/mode/haskell/haskell'; +import 'codemirror/mode/javascript/javascript'; +import 'codemirror/mode/go/go'; +import 'codemirror/mode/ruby/ruby'; + +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) { + const data = await request.ajax({ + url: '/preview', + 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/ui/components/contest/contest.page.styl b/ui/components/contest/contest.page.styl new file mode 100644 index 000000000..7fd9ba71b --- /dev/null +++ b/ui/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/ui/components/contest/contest_sidebar.page.styl b/ui/components/contest/contest_sidebar.page.styl new file mode 100644 index 000000000..8e684f686 --- /dev/null +++ b/ui/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/ui/components/contest/problem-contest-bg.png b/ui/components/contest/problem-contest-bg.png new file mode 100644 index 000000000..63ea08cad Binary files /dev/null and b/ui/components/contest/problem-contest-bg.png differ diff --git a/ui/components/contest/problem-contest-bg@2x.png b/ui/components/contest/problem-contest-bg@2x.png new file mode 100644 index 000000000..16272e1af Binary files /dev/null and b/ui/components/contest/problem-contest-bg@2x.png differ diff --git a/ui/components/datepicker/datepicker.page.js b/ui/components/datepicker/datepicker.page.js new file mode 100644 index 000000000..597b1f061 --- /dev/null +++ b/ui/components/datepicker/datepicker.page.js @@ -0,0 +1,26 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; + +import 'pickadate/lib/themes/classic.css'; +import 'pickadate/lib/themes/classic.date.css'; +import 'pickadate/lib/themes/classic.time.css'; +import './datepicker.styl'; + +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/ui/components/datepicker/datepicker.styl b/ui/components/datepicker/datepicker.styl new file mode 100644 index 000000000..16e60db05 --- /dev/null +++ b/ui/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/ui/components/dialog/DomDialog.js b/ui/components/dialog/DomDialog.js new file mode 100644 index 000000000..a401f143e --- /dev/null +++ b/ui/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/ui/components/dialog/dialog.page.styl b/ui/components/dialog/dialog.page.styl new file mode 100644 index 000000000..b4a5da94e --- /dev/null +++ b/ui/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/ui/components/dialog/index.js b/ui/components/dialog/index.js new file mode 100644 index 000000000..af635b938 --- /dev/null +++ b/ui/components/dialog/index.js @@ -0,0 +1,94 @@ +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, + }); + } +} diff --git a/ui/components/discussion/CommentBox.js b/ui/components/discussion/CommentBox.js new file mode 100644 index 000000000..84b588d68 --- /dev/null +++ b/ui/components/discussion/CommentBox.js @@ -0,0 +1,113 @@ +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(); // delay initialize + 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/ui/components/discussion/comments.page.js b/ui/components/discussion/comments.page.js new file mode 100644 index 000000000..b17483ec3 --- /dev/null +++ b/ui/components/discussion/comments.page.js @@ -0,0 +1,206 @@ +import 'jquery.easing'; + +import { AutoloadPage } from 'vj/misc/PageLoader'; +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/ui/components/discussion/comments.page.styl b/ui/components/discussion/comments.page.styl new file mode 100644 index 000000000..4cb8106bc --- /dev/null +++ b/ui/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/ui/components/discussion/discussion.page.styl b/ui/components/discussion/discussion.page.styl new file mode 100644 index 000000000..387ea3a4d --- /dev/null +++ b/ui/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/ui/components/drop/drop.page.styl b/ui/components/drop/drop.page.styl new file mode 100644 index 000000000..a5b964884 --- /dev/null +++ b/ui/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/ui/components/dropdown/Dropdown.js b/ui/components/dropdown/Dropdown.js new file mode 100644 index 000000000..9b48a8b1e --- /dev/null +++ b/ui/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/ui/components/dropdown/dropdown.page.js b/ui/components/dropdown/dropdown.page.js new file mode 100644 index 000000000..a1d2587b1 --- /dev/null +++ b/ui/components/dropdown/dropdown.page.js @@ -0,0 +1,8 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; +import Dropdown from './Dropdown'; + +const dropdownPage = new AutoloadPage('dropdownPage', () => { + Dropdown.registerLifeCycleHooks(); +}); + +export default dropdownPage; diff --git a/ui/components/dropdown/dropdown.page.styl b/ui/components/dropdown/dropdown.page.styl new file mode 100644 index 000000000..6cac894cc --- /dev/null +++ b/ui/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/ui/components/emoji/emoji.page.styl b/ui/components/emoji/emoji.page.styl new file mode 100644 index 000000000..7991fd566 --- /dev/null +++ b/ui/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/ui/components/emoji/emojify.page.js b/ui/components/emoji/emojify.page.js new file mode 100644 index 000000000..b911fe28b --- /dev/null +++ b/ui/components/emoji/emojify.page.js @@ -0,0 +1,21 @@ +import emojify from 'emojify.js'; + +import { AutoloadPage } from 'vj/misc/PageLoader'; + +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/ui/components/footer/footer.page.js b/ui/components/footer/footer.page.js new file mode 100644 index 000000000..0019581d8 --- /dev/null +++ b/ui/components/footer/footer.page.js @@ -0,0 +1,31 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; +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/ui/components/footer/footer.page.styl b/ui/components/footer/footer.page.styl new file mode 100644 index 000000000..517bf4b9b --- /dev/null +++ b/ui/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/ui/components/form/button.page.styl b/ui/components/form/button.page.styl new file mode 100644 index 000000000..fc4064cd0 --- /dev/null +++ b/ui/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/ui/components/form/checkbox.page.styl b/ui/components/form/checkbox.page.styl new file mode 100644 index 000000000..889e85591 --- /dev/null +++ b/ui/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/ui/components/form/form.page.js b/ui/components/form/form.page.js new file mode 100644 index 000000000..1e78cf45b --- /dev/null +++ b/ui/components/form/form.page.js @@ -0,0 +1,15 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; + +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/ui/components/form/form.page.styl b/ui/components/form/form.page.styl new file mode 100644 index 000000000..8ab99bd06 --- /dev/null +++ b/ui/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/ui/components/form/multiSelectCheckbox.page.js b/ui/components/form/multiSelectCheckbox.page.js new file mode 100644 index 000000000..e1128370d --- /dev/null +++ b/ui/components/form/multiSelectCheckbox.page.js @@ -0,0 +1,45 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; + +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/ui/components/form/radiobox.page.styl b/ui/components/form/radiobox.page.styl new file mode 100644 index 000000000..e4a9ff6ff --- /dev/null +++ b/ui/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/ui/components/form/select.page.styl b/ui/components/form/select.page.styl new file mode 100644 index 000000000..125cdf03f --- /dev/null +++ b/ui/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/ui/components/form/textbox.page.js b/ui/components/form/textbox.page.js new file mode 100644 index 000000000..83207946a --- /dev/null +++ b/ui/components/form/textbox.page.js @@ -0,0 +1,20 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; + +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/ui/components/form/textbox.page.styl b/ui/components/form/textbox.page.styl new file mode 100644 index 000000000..feda6bc00 --- /dev/null +++ b/ui/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/ui/components/form/var.inc.styl b/ui/components/form/var.inc.styl new file mode 100644 index 000000000..65117e174 --- /dev/null +++ b/ui/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/ui/components/header/header-background.png b/ui/components/header/header-background.png new file mode 100644 index 000000000..ae4c578af Binary files /dev/null and b/ui/components/header/header-background.png differ diff --git a/ui/components/header/header-background@2x.png b/ui/components/header/header-background@2x.png new file mode 100644 index 000000000..8d6d2692e Binary files /dev/null and b/ui/components/header/header-background@2x.png differ diff --git a/ui/components/header/header-logo-summer.png b/ui/components/header/header-logo-summer.png new file mode 100644 index 000000000..eaf4fda4c Binary files /dev/null and b/ui/components/header/header-logo-summer.png differ diff --git a/ui/components/header/header-logo-summer@2x.png b/ui/components/header/header-logo-summer@2x.png new file mode 100644 index 000000000..e4ab84b54 Binary files /dev/null and b/ui/components/header/header-logo-summer@2x.png differ diff --git a/ui/components/header/header-logo.png b/ui/components/header/header-logo.png new file mode 100644 index 000000000..a79da8a81 Binary files /dev/null and b/ui/components/header/header-logo.png differ diff --git a/ui/components/header/header-logo@2x.png b/ui/components/header/header-logo@2x.png new file mode 100755 index 000000000..4ba9d3639 Binary files /dev/null and b/ui/components/header/header-logo@2x.png differ diff --git a/ui/components/header/header.page.styl b/ui/components/header/header.page.styl new file mode 100644 index 000000000..08290169e --- /dev/null +++ b/ui/components/header/header.page.styl @@ -0,0 +1,171 @@ +$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 + position: relative + height: rem($header-bg-height) + background: #5eafca url(./header-background.png) no-repeat center top + background-size: cover + transition: height .5s + transition-timing-function: ease-out-cubic + overflow: hidden + +retina() + background-image: url(./header-background.png) + 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 + display: block + width: rem($header-logo-width-real) + height: rem($header-logo-height + $header-gap-mini + $nav-item-height) + background: url(header-logo-summer.png) 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 + +retina() + background-image: url(./header-logo-summer@2x.png) + +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) + background: url('../navigation/nav-logo-small_light.png') no-repeat + +retina() + background: url('../navigation/nav-logo-small@2x_light.png') no-repeat + 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/ui/components/highlighter/codemirror.page.styl b/ui/components/highlighter/codemirror.page.styl new file mode 100644 index 000000000..c18494ba8 --- /dev/null +++ b/ui/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/ui/components/highlighter/highlighter.page.js b/ui/components/highlighter/highlighter.page.js new file mode 100644 index 000000000..297003b64 --- /dev/null +++ b/ui/components/highlighter/highlighter.page.js @@ -0,0 +1,14 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; + +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/ui/components/highlighter/highlighter.page.styl b/ui/components/highlighter/highlighter.page.styl new file mode 100644 index 000000000..b083d40ba --- /dev/null +++ b/ui/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/ui/components/highlighter/meta.js b/ui/components/highlighter/meta.js new file mode 100644 index 000000000..3939c94a8 --- /dev/null +++ b/ui/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/ui/components/highlighter/prismjs.js b/ui/components/highlighter/prismjs.js new file mode 100644 index 000000000..da1d0e3a3 --- /dev/null +++ b/ui/components/highlighter/prismjs.js @@ -0,0 +1,72 @@ +/* + +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 +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('Code copied to clipboard!'), 1000); + }); + clip.on('error', () => { + Notification.error(i18n('Copy failed :(')); + }); + return linkCopy; +}); + +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/ui/components/homework/homework.page.styl b/ui/components/homework/homework.page.styl new file mode 100644 index 000000000..052463d17 --- /dev/null +++ b/ui/components/homework/homework.page.styl @@ -0,0 +1,4 @@ +.homework-status--text + for key, value in $homework-status-color + &.{key} + color: value diff --git a/ui/components/hotkey/hotkey.page.js b/ui/components/hotkey/hotkey.page.js new file mode 100644 index 000000000..a72341573 --- /dev/null +++ b/ui/components/hotkey/hotkey.page.js @@ -0,0 +1,80 @@ +import _ from 'lodash'; +import { AutoloadPage } from 'vj/misc/PageLoader'; + +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/ui/components/katex/katex.page.js b/ui/components/katex/katex.page.js new file mode 100644 index 000000000..e7dd9a86a --- /dev/null +++ b/ui/components/katex/katex.page.js @@ -0,0 +1,16 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; + +import 'katex/dist/katex.min.css'; +import './katex.styl'; + +const katexPage = new AutoloadPage('katexPage', () => { + import('katex/dist/contrib/auto-render.min.js').then((renderKatex) => { + function runKatex($containers) { + $containers.get().forEach(container => renderKatex(container)); + } + runKatex($('.typo')); + $(document).on('vjContentNew', e => runKatex($(e.target).find('.typo'))); + }); +}); + +export default katexPage; diff --git a/ui/components/katex/katex.styl b/ui/components/katex/katex.styl new file mode 100644 index 000000000..bcc2a6009 --- /dev/null +++ b/ui/components/katex/katex.styl @@ -0,0 +1,2 @@ +.katex + font-size: 1em diff --git a/ui/components/loader/loader.page.styl b/ui/components/loader/loader.page.styl new file mode 100644 index 000000000..a243a9239 --- /dev/null +++ b/ui/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/ui/components/marker/Marker.js b/ui/components/marker/Marker.js new file mode 100644 index 000000000..c701edbaa --- /dev/null +++ b/ui/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/ui/components/marker/MarkerReactive.js b/ui/components/marker/MarkerReactive.js new file mode 100644 index 000000000..75968c98e --- /dev/null +++ b/ui/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/ui/components/marker/marker.page.js b/ui/components/marker/marker.page.js new file mode 100644 index 000000000..dab92ff96 --- /dev/null +++ b/ui/components/marker/marker.page.js @@ -0,0 +1,8 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; +import MarkerReactive from './MarkerReactive'; + +const markerPage = new AutoloadPage('markerPage', () => { + MarkerReactive.initAll(); +}); + +export default markerPage; diff --git a/ui/components/marker/marker.page.styl b/ui/components/marker/marker.page.styl new file mode 100644 index 000000000..33fc3c3c1 --- /dev/null +++ b/ui/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/ui/components/menu/menu-heading.page.js b/ui/components/menu/menu-heading.page.js new file mode 100644 index 000000000..03384ca1f --- /dev/null +++ b/ui/components/menu/menu-heading.page.js @@ -0,0 +1,30 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; + +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/ui/components/menu/menu.page.js b/ui/components/menu/menu.page.js new file mode 100644 index 000000000..e8885a490 --- /dev/null +++ b/ui/components/menu/menu.page.js @@ -0,0 +1,18 @@ +import { AutoloadPage } from 'vj/misc/PageLoader'; +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/ui/components/menu/menu.page.styl b/ui/components/menu/menu.page.styl new file mode 100644 index 000000000..4b301cc37 --- /dev/null +++ b/ui/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/ui/components/messagepad/DialogueListItem.page.styl b/ui/components/messagepad/DialogueListItem.page.styl new file mode 100644 index 000000000..f8ef155ed --- /dev/null +++ b/ui/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/ui/components/messagepad/DialogueListItemComponent.js b/ui/components/messagepad/DialogueListItemComponent.js new file mode 100644 index 000000000..b2777109c --- /dev/null +++ b/ui/components/messagepad/DialogueListItemComponent.js @@ -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/ui/components/messagepad/Message.page.styl b/ui/components/messagepad/Message.page.styl new file mode 100644 index 000000000..fc443afe9 --- /dev/null +++ b/ui/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/ui/components/messagepad/MessageComponent.js b/ui/components/messagepad/MessageComponent.js new file mode 100644 index 000000000..de0c3ceb8 --- /dev/null +++ b/ui/components/messagepad/MessageComponent.js @@ -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/ui/components/messagepad/MessagePad.page.styl b/ui/components/messagepad/MessagePad.page.styl new file mode 100644 index 000000000..f6b9e787d --- /dev/null +++ b/ui/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/ui/components/messagepad/MessagePadDialogueContentContainer.js b/ui/components/messagepad/MessagePadDialogueContentContainer.js new file mode 100644 index 000000000..f8f14a1aa --- /dev/null +++ b/ui/components/messagepad/MessagePadDialogueContentContainer.js @@ -0,0 +1,81 @@ +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 Message from './MessageComponent'; + +const mapStateToProps = state => ({ + activeId: state.activeId, + item: state.activeId !== null + ? state.dialogues[state.activeId] + : null, +}); + +@connect(mapStateToProps, null) +export default class MessagePadDialogueContentContainer extends React.PureComponent { + componentDidMount() { + $(this.refs.list).scrollLock({ strict: true }); + } + + componentWillUpdate(nextProps) { + if (nextProps.activeId !== this.props.activeId) { + this.scrollToBottom = true; + this.scrollWithAnimation = false; + return; + } + const node = this.refs.list; + if (node.scrollTop + node.offsetHeight === node.scrollHeight) { + this.scrollToBottom = true; + this.scrollWithAnimation = true; + return; + } + this.scrollToBottom = false; + } + + componentDidUpdate() { + if (this.scrollToBottom) { + const node = this.refs.list; + 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.reply, (reply, idx) => ( + +
    {reply.content}
    + +
    + )); + } + + render() { + return ( +
      + {this.renderInner()} +
    + ); + } +} diff --git a/ui/components/messagepad/MessagePadDialogueListContainer.js b/ui/components/messagepad/MessagePadDialogueListContainer.js new file mode 100644 index 000000000..4e81af36f --- /dev/null +++ b/ui/components/messagepad/MessagePadDialogueListContainer.js @@ -0,0 +1,63 @@ +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, + }); + }, +}); + +@connect(mapStateToProps, mapDispatchToProps) +export default class MessagePadDialogueListContainer extends React.PureComponent { + componentDidMount() { + $(this.refs.list).scrollLock({ strict: true }); + } + + render() { + const orderedDialogues = _.orderBy( + _.values(this.props.dialogues), + dialogue => (dialogue.isPlaceholder + ? Number.POSITIVE_INFINITY + : _.maxBy(dialogue.reply, 'at').at), + 'desc', + ); + return ( +
      + {_.map(orderedDialogues, dialogue => ( + this.props.handleClick(dialogue._id)} + /> + ))} +
    + ); + } +} diff --git a/ui/components/messagepad/MessagePadInputContainer.js b/ui/components/messagepad/MessagePadInputContainer.js new file mode 100644 index 000000000..4a6ba82c5 --- /dev/null +++ b/ui/components/messagepad/MessagePadInputContainer.js @@ -0,0 +1,135 @@ +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(placeholderId, uid, value) { + if (placeholderId === null) { + return; + } + const req = request.post('', { + operation: 'send_message', + uid, + content: value, + }); + dispatch({ + type: 'DIALOGUES_POST_SEND', + payload: req, + meta: { + placeholderId, + }, + }); + }, + postReply(id, value) { + if (id === null) { + return; + } + const req = request.post('', { + operation: 'reply_message', + message_id: id, + content: value, + }); + dispatch({ + type: 'DIALOGUES_POST_REPLY', + payload: req, + meta: { + dialogueId: id, + }, + }); + }, +}); + +@connect(mapStateToProps, mapDispatchToProps) +export default class MessagePadInputContainer extends React.PureComponent { + static contextTypes = { + store: PropTypes.object, + }; + + componentWillUpdate(nextProps) { + this.focusInput = ( + nextProps.activeId !== this.props.activeId + || this.props.isPosting !== nextProps.isPosting && nextProps.isPosting === false + ); + } + + componentDidUpdate() { + 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() { + const state = this.context.store.getState(); + if (state.dialogues[this.props.activeId].isPlaceholder) { + this.props.postSend( + this.props.activeId, + state.dialogues[this.props.activeId].sendee_uid, + this.props.inputValue, + ); + } else { + this.props.postReply( + this.props.activeId, + this.props.inputValue, + ); + } + } + + render() { + const cn = classNames('messagepad__input', { + visible: this.props.activeId !== null, + }); + return ( +
    +
    +