mizozobu 6 лет назад
Родитель
Сommit
df7aca35c8
100 измененных файлов с 7582 добавлено и 6579 удалено
  1. 10 1
      CHANGES.md
  2. 1 0
      config/logger/config.dev.js
  3. 3 3
      package.json
  4. 15 7
      resource/locales/en-US/translation.json
  5. 13 4
      resource/locales/ja/translation.json
  6. 123 466
      src/client/js/app.js
  7. 19 5
      src/client/js/components/LikeButton.jsx
  8. 196 0
      src/client/js/components/MyDraftList/Draft.jsx
  9. 55 24
      src/client/js/components/MyDraftList/MyDraftList.jsx
  10. 28 20
      src/client/js/components/Page.jsx
  11. 19 6
      src/client/js/components/Page/RevisionLoader.jsx
  12. 34 19
      src/client/js/components/Page/RevisionRenderer.jsx
  13. 10 49
      src/client/js/components/Page/TagEditor.jsx
  14. 94 36
      src/client/js/components/Page/TagLabels.jsx
  15. 16 4
      src/client/js/components/Page/TagsInput.jsx
  16. 23 9
      src/client/js/components/PageAttachment.js
  17. 147 30
      src/client/js/components/PageComment/Comment.jsx
  18. 348 0
      src/client/js/components/PageComment/CommentEditor.jsx
  19. 100 0
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  20. 0 381
      src/client/js/components/PageComment/CommentForm.jsx
  21. 1 2
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  22. 0 256
      src/client/js/components/PageComments.js
  23. 254 0
      src/client/js/components/PageComments.jsx
  24. 50 58
      src/client/js/components/PageEditor.jsx
  25. 0 1
      src/client/js/components/PageEditor/AbstractEditor.jsx
  26. 0 0
      src/client/js/components/PageEditor/Cheatsheet.jsx
  27. 56 35
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  28. 17 10
      src/client/js/components/PageEditor/Editor.jsx
  29. 0 0
      src/client/js/components/PageEditor/MarkdownTableUtil.jsx
  30. 56 54
      src/client/js/components/PageEditor/OptionsSelector.jsx
  31. 0 47
      src/client/js/components/PageEditor/Preview.js
  32. 50 0
      src/client/js/components/PageEditor/Preview.jsx
  33. 0 0
      src/client/js/components/PageEditor/SimpleCheatsheet.jsx
  34. 0 0
      src/client/js/components/PageEditor/TextAreaEditor.jsx
  35. 39 66
      src/client/js/components/PageEditorByHackmd.jsx
  36. 0 167
      src/client/js/components/PageList/Draft.jsx
  37. 1 3
      src/client/js/components/PageList/PagePath.js
  38. 30 43
      src/client/js/components/PageStatusAlert.jsx
  39. 0 28
      src/client/js/components/ReactUtils.js
  40. 22 11
      src/client/js/components/RecentCreated/RecentCreated.jsx
  41. 43 44
      src/client/js/components/SavePageControls.jsx
  42. 31 18
      src/client/js/components/SavePageControls/GrantSelector.jsx
  43. 15 3
      src/client/js/components/SearchForm.js
  44. 14 7
      src/client/js/components/SearchPage.js
  45. 15 3
      src/client/js/components/SearchPage/SearchPageForm.js
  46. 26 22
      src/client/js/components/SearchPage/SearchResult.js
  47. 16 8
      src/client/js/components/SearchPage/SearchResultList.js
  48. 15 4
      src/client/js/components/SearchTypeahead.js
  49. 22 38
      src/client/js/components/SlackNotification.jsx
  50. 61 0
      src/client/js/components/UnstatedUtils.jsx
  51. 16 3
      src/client/js/components/User/UserPictureList.jsx
  52. 1 1
      src/client/js/installer.jsx
  53. 35 37
      src/client/js/legacy/crowi.js
  54. 6 6
      src/client/js/plugin.js
  55. 148 104
      src/client/js/services/AppContainer.js
  56. 117 0
      src/client/js/services/CommentContainer.js
  57. 163 0
      src/client/js/services/EditorContainer.js
  58. 173 0
      src/client/js/services/PageContainer.js
  59. 54 0
      src/client/js/services/TagContainer.js
  60. 33 0
      src/client/js/services/WebsocketContainer.js
  61. 47 53
      src/client/js/util/GrowiRenderer.js
  62. 5 2
      src/client/js/util/PostProcessor/CrowiTemplate.js
  63. 0 0
      src/client/js/util/i18n.js
  64. 8 12
      src/client/js/util/reveal/plugins/growi-renderer.js
  65. 51 51
      src/client/styles/bootstrap4/_alert.scss
  66. 47 47
      src/client/styles/bootstrap4/_badge.scss
  67. 41 38
      src/client/styles/bootstrap4/_breadcrumb.scss
  68. 172 166
      src/client/styles/bootstrap4/_button-group.scss
  69. 143 143
      src/client/styles/bootstrap4/_buttons.scss
  70. 301 270
      src/client/styles/bootstrap4/_card.scss
  71. 236 191
      src/client/styles/bootstrap4/_carousel.scss
  72. 35 34
      src/client/styles/bootstrap4/_close.scss
  73. 48 56
      src/client/styles/bootstrap4/_code.scss
  74. 433 297
      src/client/styles/bootstrap4/_custom-forms.scss
  75. 166 131
      src/client/styles/bootstrap4/_dropdown.scss
  76. 333 333
      src/client/styles/bootstrap4/_forms.scss
  77. 87 87
      src/client/styles/bootstrap4/_functions.scss
  78. 52 52
      src/client/styles/bootstrap4/_grid.scss
  79. 42 42
      src/client/styles/bootstrap4/_images.scss
  80. 173 159
      src/client/styles/bootstrap4/_input-group.scss
  81. 16 16
      src/client/styles/bootstrap4/_jumbotron.scss
  82. 115 115
      src/client/styles/bootstrap4/_list-group.scss
  83. 8 8
      src/client/styles/bootstrap4/_media.scss
  84. 3 4
      src/client/styles/bootstrap4/_mixins.scss
  85. 180 168
      src/client/styles/bootstrap4/_modal.scss
  86. 118 118
      src/client/styles/bootstrap4/_nav.scss
  87. 299 311
      src/client/styles/bootstrap4/_navbar.scss
  88. 78 77
      src/client/styles/bootstrap4/_pagination.scss
  89. 183 183
      src/client/styles/bootstrap4/_popover.scss
  90. 141 124
      src/client/styles/bootstrap4/_print.scss
  91. 34 33
      src/client/styles/bootstrap4/_progress.scss
  92. 483 482
      src/client/styles/bootstrap4/_reboot.scss
  93. 19 19
      src/client/styles/bootstrap4/_root.scss
  94. 187 180
      src/client/styles/bootstrap4/_tables.scss
  95. 115 115
      src/client/styles/bootstrap4/_tooltip.scss
  96. 22 36
      src/client/styles/bootstrap4/_transitions.scss
  97. 125 125
      src/client/styles/bootstrap4/_type.scss
  98. 5 4
      src/client/styles/bootstrap4/_utilities.scss
  99. 165 122
      src/client/styles/bootstrap4/_variables.scss
  100. 32 32
      src/client/styles/bootstrap4/bootstrap-grid.scss

+ 10 - 1
CHANGES.md

@@ -1,6 +1,15 @@
 # CHANGES
 
-## 3.4.7-RC
+## 3.4.8-RC
+
+* Feature: Comment Thread
+* Improvement Draft list
+* Support: Apply unstated
+* Support: Upgrade libs
+    * mini-css-extract-plugin
+    * null-loader
+
+## 3.4.7
 
 * Improvement: Handle private pages on group deletion
 * Fix: Searching with `tag:xxx` syntax doesn't work

+ 1 - 0
config/logger/config.dev.js

@@ -27,4 +27,5 @@ module.exports = {
    * configure level for client
    */
   'growi:app': 'debug',
+  'growi:services:*': 'debug',
 };

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.4.7-RC",
+  "version": "3.4.8-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -180,14 +180,14 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
-    "mini-css-extract-plugin": "^0.6.0",
+    "mini-css-extract-plugin": "^0.7.0",
     "mocha": "^6.0.1",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-sass": "^4.11.0",
     "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
-    "null-loader": "^1.0.0",
+    "null-loader": "^2.0.0",
     "on-headers": "^1.0.1",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "penpal": "^4.0.0",

+ 15 - 7
resource/locales/en-US/translation.json

@@ -2,6 +2,7 @@
   "Help": "Help",
   "Edit": "Edit",
   "Delete": "Delete",
+  "Delete All": "Delete All",
   "Duplicate": "Duplicate",
   "Copy": "Copy",
   "Click to copy": "Click to copy",
@@ -24,6 +25,10 @@
   "Page Path": "Page Path",
   "Category": "Category",
   "User": "User",
+  "User Name": "User Name",
+  "User List": "User List",
+  "Add": "Add",
+  "Method": "Method",
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -46,6 +51,7 @@
 
   "Created": "Created",
   "Last updated": "Updated",
+  "Last Login": "Last Login",
 
   "Share": "Share",
   "Share Link": "Share Link",
@@ -107,7 +113,7 @@
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
-  "Basic settings": "Basic settings",
+  "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Guest users access": "Guest users access",
   "Register limitation": "Register limitation",
@@ -680,12 +686,9 @@
     "give_admin_access": "Give admin access",
     "remove_admin_access": "Remove admin access",
     "external_account": "External account management",
-    "user_list": "List of users",
     "external_account_list": "External Account List",
     "back_to_user_management": "Back to User Management",
     "authentication_provider": "Authentication Provider",
-    "Date created": "Date created",
-    "Last login": "Last login",
     "Manage": "Manage",
     "Edit menu": "Edit menu",
     "password_setting": "Password Setting",
@@ -705,11 +708,12 @@
   },
 
   "user_group_management": {
-    "group_list": "List of Group",
+    "group_list": "Group List",
+    "back_to_list": "Go Back to Group List",
     "create_group": "Create New Group",
     "group_example": "e.g. : Group1",
     "created_group": "Group was created",
-    "add_user": "Add a user to the created group",
+    "add_user": "Add a User to the Created Group",
     "deny_create_group": "You can't create a new group with the current settings",
     "choose_action": "Choose an action for private pages",
     "delete_group": "Delete Group",
@@ -719,7 +723,11 @@
     "delete_pages": "Delete All",
     "transfer_pages": "Transfer to another group",
     "select_group": "Select a group",
-    "no_groups": "No groups to select"
+    "no_groups": "No groups to select",
+    "no_pages": "There are no pages the group has view permission",
+    "how_to_add1": "Enter a username to add",
+    "how_to_add2": "Select a user from user list",
+    "remove_from_group": "Remove this group"
   },
 
   "importer_management": {

+ 13 - 4
resource/locales/ja/translation.json

@@ -2,6 +2,7 @@
   "Help": "ヘルプ",
   "Edit": "編集",
   "Delete": "削除",
+  "Delete All": "全て削除",
   "Duplicate": "複製",
   "Copy": "コピー",
   "Click to copy": "クリックでコピー",
@@ -24,6 +25,10 @@
   "Page Path": "ページパス",
   "Category": "カテゴリー",
   "User": "ユーザー",
+  "User Name": "ユーザーネーム",
+  "User List": "ユーザーリスト",
+  "Add": "追加",
+  "Method": "方法",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -46,6 +51,7 @@
 
   "Created": "作成日",
   "Last updated": "最終更新",
+  "Last Login": "最終ログイン",
 
   "Share": "共有",
   "Share Link": "共有用リンク",
@@ -107,7 +113,7 @@
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
-  "Basic settings": "基本設定",
+  "Basic Settings": "基本設定",
   "Basic authentication": "Basic認証",
   "Guest users access": "ゲストユーザーのアクセス",
   "Register limitation": "登録の制限",
@@ -684,8 +690,6 @@
     "external_account_list": "外部アカウント一覧",
     "back_to_user_management": "ユーザー管理に戻る",
     "authentication_provider": "認証情報プロバイダ",
-    "Date created": "作成日",
-    "Last login": "最終ログイン",
     "Manage": "操作",
     "Edit menu": "編集メニュー",
     "password_setting": "パスワード設定",
@@ -706,6 +710,7 @@
 
   "user_group_management": {
     "group_list": "グループ一覧",
+    "back_to_list": "グループ一覧に戻る",
     "create_group": "新規グループの作成",
     "group_example": "例: Group1",
     "created_group": "グループを作成しました",
@@ -719,7 +724,11 @@
     "delete_pages": "全て削除する",
     "transfer_pages": "全て他のグループに移譲する",
     "select_group": "グループを選択してください",
-    "no_groups": "グループがありません"
+    "no_groups": "グループがありません",
+    "no_pages": "グループが閲覧権限を保有するページはありません",
+    "how_to_add1": "ユーザー名を入力して追加",
+    "how_to_add2": "ユーザーを下のリストから選択",
+    "remove_from_group": "グループから外す"
   },
 
   "importer_management": {

+ 123 - 466
src/client/js/app.js

@@ -8,13 +8,6 @@ import * as toastr from 'toastr';
 
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
-import * as entities from 'entities';
-import i18nFactory from './i18n';
-
-
-import Crowi from './util/Crowi';
-// import CrowiRenderer from './util/CrowiRenderer';
-import GrowiRenderer from './util/GrowiRenderer';
 
 import HeaderSearchBox from './components/HeaderSearchBox';
 import SearchPage from './components/SearchPage';
@@ -23,13 +16,13 @@ import PageEditor from './components/PageEditor';
 // eslint-disable-next-line import/no-duplicates
 import OptionsSelector from './components/PageEditor/OptionsSelector';
 // eslint-disable-next-line import/no-duplicates
-import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
+import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
 import SavePageControls from './components/SavePageControls';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
-import CommentForm from './components/PageComment/CommentForm';
+import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
@@ -47,6 +40,12 @@ import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
+import AppContainer from './services/AppContainer';
+import PageContainer from './services/PageContainer';
+import CommentContainer from './services/CommentContainer';
+import EditorContainer from './services/EditorContainer';
+import TagContainer from './services/TagContainer';
+import WebsocketContainer from './services/WebsocketContainer';
 
 const logger = loggerFactory('growi:app');
 
@@ -54,91 +53,35 @@ if (!window) {
   window = {};
 }
 
-const userlang = $('body').data('userlang');
-const i18n = i18nFactory(userlang);
-
 // setup xss library
 const xss = new Xss();
 window.xss = xss;
 
-const mainContent = document.querySelector('#content-main');
-let pageId = null;
-let pageRevisionId = null;
-let pageRevisionCreatedAt = null;
-let pageRevisionIdHackmdSynced = null;
-let hasDraftOnHackmd = false;
-let pageIdOnHackmd = null;
-let pagePath;
-let pageContent = '';
-let markdown = '';
-let slackChannels;
-let pageTags = [];
-let templateTagData = '';
-if (mainContent !== null) {
-  pageId = mainContent.getAttribute('data-page-id') || null;
-  pageRevisionId = mainContent.getAttribute('data-page-revision-id');
-  pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
-  pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
-  pageIdOnHackmd = mainContent.getAttribute('data-page-id-on-hackmd') || null;
-  hasDraftOnHackmd = !!mainContent.getAttribute('data-page-has-draft-on-hackmd');
-  pagePath = mainContent.attributes['data-path'].value;
-  slackChannels = mainContent.getAttribute('data-slack-channels') || '';
-  templateTagData = mainContent.getAttribute('data-template-tags') || '';
-  const rawText = document.getElementById('raw-text-original');
-  if (rawText) {
-    pageContent = rawText.innerHTML;
-  }
-  markdown = entities.decodeHTML(pageContent);
-}
-const isLoggedin = document.querySelector('.main-container.nologin') == null;
-
-// FIXME
-const crowi = new Crowi({
-  me: $('body').data('current-username'),
-  isAdmin: $('body').data('is-admin'),
-  csrfToken: $('body').data('csrftoken'),
-}, window);
-window.crowi = crowi;
-crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));
-if (isLoggedin) {
-  crowi.fetchUsers();
-}
-const socket = crowi.getWebSocket();
-const socketClientId = crowi.getSocketClientId();
+// create unstated container instance
+const appContainer = new AppContainer();
+const websocketContainer = new WebsocketContainer(appContainer);
+const pageContainer = new PageContainer(appContainer);
+const commentContainer = new CommentContainer(appContainer);
+const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
+const tagContainer = new TagContainer(appContainer);
+const injectableContainers = [
+  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer,
+];
 
-const crowiRenderer = new GrowiRenderer(crowi, null, {
-  mode: 'page',
-  isAutoSetup: false, // manually setup because plugins may configure it
-  renderToc: crowi.getCrowiForJquery().renderTocContent, // function for rendering Table Of Contents
-});
-window.crowiRenderer = crowiRenderer;
+logger.info('unstated containers have been initialized');
 
-// FIXME
-const isEnabledPlugins = $('body').data('plugin-enabled');
-if (isEnabledPlugins) {
-  const crowiPlugin = window.crowiPlugin;
-  crowiPlugin.installAll(crowi, crowiRenderer);
-}
+appContainer.initPlugins();
+appContainer.injectToWindow();
 
-/**
- * receive tags from PageTagForm
- * @param {Array} tagData new tags
- */
-const setTagData = function(tagData) {
-  pageTags = tagData;
-};
-
-/**
- * component store
- */
-const componentInstances = {};
+const i18n = appContainer.i18n;
 
 /**
  * save success handler when reloading is not needed
  * @param {object} page Page instance
  */
-const saveWithShortcutSuccessHandler = function(page) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+const saveWithShortcutSuccessHandler = function(result) {
+  const { page, tags } = result;
+  const { editorMode } = appContainer.state;
 
   // show toastr
   toastr.success(undefined, 'Saved successfully', {
@@ -151,41 +94,40 @@ const saveWithShortcutSuccessHandler = function(page) {
     extendedTimeOut: '150',
   });
 
-  pageId = page._id;
-  pageRevisionId = page.revision._id;
-  pageRevisionIdHackmdSynced = page.revisionHackmdSynced;
+  // update state of PageContainer
+  const newState = {
+    pageId: page._id,
+    revisionId: page.revision._id,
+    revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
+    remoteRevisionId: page.revision._id,
+    revisionIdHackmdSynced: page.revisionHackmdSynced,
+    hasDraftOnHackmd: page.hasDraftOnHackmd,
+    markdown: page.revision.body,
+    tags,
+  };
+  pageContainer.setState(newState);
 
-  // set page id to SavePageControls
-  componentInstances.savePageControls.setPageId(pageId);
+  // update state of EditorContainer
+  editorContainer.setState({ tags });
 
-  // Page component
-  if (componentInstances.page != null) {
-    componentInstances.page.setMarkdown(page.revision.body);
-  }
   // PageEditor component
-  if (componentInstances.pageEditor != null) {
-    const updateEditorValue = (editorMode !== 'builtin');
-    componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
+  const pageEditor = appContainer.getComponentInstance('PageEditor');
+  if (pageEditor != null) {
+    if (editorMode !== 'builtin') {
+      pageEditor.updateEditorValue(newState.markdown);
+    }
   }
   // PageEditorByHackmd component
-  if (componentInstances.pageEditorByHackmd != null) {
-    // clear state of PageEditorByHackmd
-    componentInstances.pageEditorByHackmd.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
+  const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
+  if (pageEditorByHackmd != null) {
     // reset
     if (editorMode !== 'hackmd') {
-      componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, false);
-      componentInstances.pageEditorByHackmd.reset();
+      pageEditorByHackmd.reset();
     }
   }
-  // PageStatusAlert component
-  const pageStatusAlert = componentInstances.pageStatusAlert;
-  // clear state of PageStatusAlert
-  if (componentInstances.pageStatusAlert != null) {
-    pageStatusAlert.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
-  }
 
   // hidden input
-  $('input[name="revision_id"]').val(pageRevisionId);
+  $('input[name="revision_id"]').val(newState.revisionId);
 };
 
 const errorHandler = function(error) {
@@ -200,27 +142,28 @@ const errorHandler = function(error) {
 };
 
 const saveWithShortcut = function(markdown) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+  const { editorMode } = appContainer.state;
+
+  const { pageId, path } = pageContainer.state;
+  let { revisionId } = pageContainer.state;
 
-  let revisionId = pageRevisionId;
   // get options
-  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
-  options.socketClientId = socketClientId;
-  options.pageTags = pageTags;
+  const options = editorContainer.getCurrentOptionsToSave();
+  options.socketClientId = websocketContainer.getCocketClientId();
+  options.pageTags = editorContainer.state.tags;
 
   if (editorMode === 'hackmd') {
     // set option to sync
     options.isSyncRevisionToHackmd = true;
-    // use revisionId of PageEditorByHackmd
-    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    revisionId = pageContainer.state.revisionIdHackmdSynced;
   }
 
   let promise;
   if (pageId == null) {
-    promise = crowi.createPage(pagePath, markdown, options);
+    promise = appContainer.createPage(path, markdown, options);
   }
   else {
-    promise = crowi.updatePage(pageId, revisionId, markdown, options);
+    promise = appContainer.updatePage(pageId, revisionId, markdown, options);
   }
 
   promise
@@ -229,48 +172,52 @@ const saveWithShortcut = function(markdown) {
 };
 
 const saveWithSubmitButtonSuccessHandler = function() {
-  crowi.clearDraft(pagePath);
-  window.location.href = pagePath;
+  const { path } = pageContainer.state;
+  editorContainer.clearDraft(path);
+  window.location.href = path;
 };
 
 const saveWithSubmitButton = function(submitOpts) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+  const { editorMode } = appContainer.state;
   if (editorMode == null) {
     // do nothing
     return;
   }
 
-  let revisionId = pageRevisionId;
+  const { pageId, path } = pageContainer.state;
+  let { revisionId } = pageContainer.state;
   // get options
-  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
-  options.socketClientId = socketClientId;
-  options.pageTags = pageTags;
+  const options = editorContainer.getCurrentOptionsToSave();
+  options.socketClientId = websocketContainer.getSocketClientId();
+  options.pageTags = editorContainer.state.tags;
 
   // set 'submitOpts.overwriteScopesOfDescendants' to options
   options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
 
   let promise;
   if (editorMode === 'hackmd') {
+    const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
     // get markdown
-    promise = componentInstances.pageEditorByHackmd.getMarkdown();
+    promise = pageEditorByHackmd.getMarkdown();
     // use revisionId of PageEditorByHackmd
-    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    revisionId = pageContainer.state.revisionIdHackmdSynced;
     // set option to sync
     options.isSyncRevisionToHackmd = true;
   }
   else {
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
     // get markdown
-    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
+    promise = Promise.resolve(pageEditor.getMarkdown());
   }
   // create or update
   if (pageId == null) {
     promise = promise.then((markdown) => {
-      return crowi.createPage(pagePath, markdown, options);
+      return appContainer.createPage(path, markdown, options);
     });
   }
   else {
     promise = promise.then((markdown) => {
-      return crowi.updatePage(pageId, revisionId, markdown, options);
+      return appContainer.updatePage(pageId, revisionId, markdown, options);
     });
   }
 
@@ -279,281 +226,69 @@ const saveWithSubmitButton = function(submitOpts) {
     .catch(errorHandler);
 };
 
-// setup renderer after plugins are installed
-crowiRenderer.setup();
-
-// restore draft when the first time to edit
-const draft = crowi.findDraft(pagePath);
-if (!pageRevisionId && draft != null) {
-  markdown = draft;
-}
-
 /**
  * define components
  *  key: id of element
  *  value: React Element
  */
-const componentMappings = {
-  'search-top': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
-  'search-sidebar': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
-  'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
+let componentMappings = {
+  'search-top': <HeaderSearchBox crowi={appContainer} />,
+  'search-sidebar': <HeaderSearchBox crowi={appContainer} />,
+  'search-page': <SearchPage crowi={appContainer} />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
-  'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
+  'tags-page': <TagsList crowi={appContainer} />,
 
-  'tags-page': <I18nextProvider i18n={i18n}><TagsList crowi={crowi} /></I18nextProvider>,
+  'create-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} addTrailingSlash />,
 
-  'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
-  'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
-  'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
+  'page-editor': <PageEditor onSaveWithShortcut={saveWithShortcut} />,
+  'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
+  'page-status-alert': <PageStatusAlert />,
+  'save-page-controls': <SavePageControls onSubmit={saveWithSubmitButton} />,
 
+  'user-created-list': <RecentCreated />,
+  'user-draft-list': <MyDraftList />,
 };
+
 // additional definitions if data exists
-if (pageId) {
-  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
-  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
+if (pageContainer.state.pageId != null) {
+  componentMappings = Object.assign({
+    'page-editor-with-hackmd': <PageEditorByHackmd onSaveWithShortcut={saveWithShortcut} />,
+    'page-comments-list': <PageComments />,
+    'page-attachment':  <PageAttachment />,
+    'page-comment-write':  <CommentEditorLazyRenderer />,
+    'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
+    'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
+    'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
+    'bookmark-button':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
+    'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
+    'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+    'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+  }, componentMappings);
 }
-if (pagePath) {
-  componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
-  componentMappings['revision-path'] = <I18nextProvider i18n={i18n}><RevisionPath pageId={pageId} pagePath={pagePath} crowi={crowi} /></I18nextProvider>;
-  componentMappings['tag-label'] = <I18nextProvider i18n={i18n}><TagLabels crowi={crowi} pageId={pageId} sendTagData={setTagData} templateTagData={templateTagData} /></I18nextProvider>;
+if (pageContainer.state.path != null) {
+  componentMappings = Object.assign({
+    // eslint-disable-next-line quote-props
+    'page': <Page onSaveWithShortcut={saveWithShortcut} />,
+    'revision-path':  <RevisionPath pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} crowi={appContainer} />,
+    'tag-label':  <TagLabels />,
+  }, componentMappings);
 }
 
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   if (elem) {
-    componentInstances[key] = ReactDOM.render(componentMappings[key], elem);
+    ReactDOM.render(
+      <I18nextProvider i18n={i18n}>
+        <Provider inject={injectableContainers}>
+          {componentMappings[key]}
+        </Provider>
+      </I18nextProvider>,
+      elem,
+    );
   }
 });
 
-// set page if exists
-if (componentInstances.page != null) {
-  crowi.setPage(componentInstances.page);
-}
-
-// render LikeButton
-const likeButtonElem = document.getElementById('like-button');
-if (likeButtonElem) {
-  const isLiked = likeButtonElem.dataset.liked === 'true';
-  ReactDOM.render(
-    <LikeButton crowi={crowi} pageId={pageId} isLiked={isLiked} />,
-    likeButtonElem,
-  );
-}
-
-// render UserPictureList for seen-user-list
-const seenUserListElem = document.getElementById('seen-user-list');
-if (seenUserListElem) {
-  const userIdsStr = seenUserListElem.dataset.userIds;
-  const userIds = userIdsStr.split(',');
-  ReactDOM.render(
-    <UserPictureList crowi={crowi} userIds={userIds} />,
-    seenUserListElem,
-  );
-}
-// render UserPictureList for liker-list
-const likerListElem = document.getElementById('liker-list');
-if (likerListElem) {
-  const userIdsStr = likerListElem.dataset.userIds;
-  const userIds = userIdsStr.split(',');
-  ReactDOM.render(
-    <UserPictureList crowi={crowi} userIds={userIds} />,
-    likerListElem,
-  );
-}
-
-// render SavePageControls
-let savePageControls = null;
-const savePageControlsElem = document.getElementById('save-page-controls');
-if (savePageControlsElem) {
-  const grant = +savePageControlsElem.dataset.grant;
-  const grantGroupId = savePageControlsElem.dataset.grantGroup;
-  const grantGroupName = savePageControlsElem.dataset.grantGroupName;
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <SavePageControls
-        crowi={crowi}
-        onSubmit={saveWithSubmitButton}
-        ref={(elem) => {
-            if (savePageControls == null) {
-              savePageControls = elem;
-            }
-          }}
-        pageId={pageId}
-        slackChannels={slackChannels}
-        grant={grant}
-        grantGroupId={grantGroupId}
-        grantGroupName={grantGroupName}
-      />
-    </I18nextProvider>,
-    savePageControlsElem,
-  );
-  componentInstances.savePageControls = savePageControls;
-}
-
-const recentCreatedControlsElem = document.getElementById('user-created-list');
-if (recentCreatedControlsElem) {
-  let limit = crowi.getConfig().recentCreatedLimit;
-  if (limit == null) {
-    limit = 10;
-  }
-  ReactDOM.render(
-    <RecentCreated crowi={crowi} pageId={pageId} limit={limit}>
-
-    </RecentCreated>, document.getElementById('user-created-list'),
-  );
-}
-
-const myDraftControlsElem = document.getElementById('user-draft-list');
-if (myDraftControlsElem) {
-  let limit = crowi.getConfig().recentCreatedLimit;
-  if (limit == null) {
-    limit = 10;
-  }
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <MyDraftList
-        limit={limit}
-        crowi={crowi}
-        crowiOriginRenderer={crowiRenderer}
-      />
-    </I18nextProvider>,
-    myDraftControlsElem,
-  );
-}
-
-/*
- * HackMD Editor
- */
-// render PageEditorWithHackmd
-let pageEditorByHackmd = null;
-const pageEditorWithHackmdElem = document.getElementById('page-editor-with-hackmd');
-if (pageEditorWithHackmdElem) {
-  pageEditorByHackmd = ReactDOM.render(
-    <PageEditorByHackmd
-      crowi={crowi}
-      pageId={pageId}
-      revisionId={pageRevisionId}
-      pageIdOnHackmd={pageIdOnHackmd}
-      revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
-      hasDraftOnHackmd={hasDraftOnHackmd}
-      markdown={markdown}
-      onSaveWithShortcut={saveWithShortcut}
-    />,
-    pageEditorWithHackmdElem,
-  );
-  componentInstances.pageEditorByHackmd = pageEditorByHackmd;
-}
-
-
-/*
- * PageEditor
- */
-let pageEditor = null;
-const editorOptions = new EditorOptions(crowi.editorOptions);
-const previewOptions = new PreviewOptions(crowi.previewOptions);
-// render PageEditor
-const pageEditorElem = document.getElementById('page-editor');
-if (pageEditorElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <PageEditor
-        ref={(elem) => {
-          if (pageEditor == null) {
-            pageEditor = elem;
-          }
-        }}
-        crowi={crowi}
-        crowiRenderer={crowiRenderer}
-        pageId={pageId}
-        revisionId={pageRevisionId}
-        pagePath={pagePath}
-        markdown={markdown}
-        editorOptions={editorOptions}
-        previewOptions={previewOptions}
-        onSaveWithShortcut={saveWithShortcut}
-      />
-    </I18nextProvider>,
-    pageEditorElem,
-  );
-  componentInstances.pageEditor = pageEditor;
-  // set refs for setCaretLine/forceToFocus when tab is changed
-  crowi.setPageEditor(pageEditor);
-}
-
-// render comment form
-const writeCommentElem = document.getElementById('page-comment-write');
-if (writeCommentElem) {
-  const pageCommentsElem = componentInstances['page-comments-list'];
-  const postCompleteHandler = (comment) => {
-    if (pageCommentsElem != null) {
-      pageCommentsElem.retrieveData();
-    }
-  };
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <CommentForm
-        crowi={crowi}
-        crowiOriginRenderer={crowiRenderer}
-        pageId={pageId}
-        pagePath={pagePath}
-        revisionId={pageRevisionId}
-        onPostComplete={postCompleteHandler}
-        editorOptions={editorOptions}
-        slackChannels={slackChannels}
-      />
-    </I18nextProvider>,
-    writeCommentElem,
-  );
-}
-
-// render OptionsSelector
-const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
-if (pageEditorOptionsSelectorElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <OptionsSelector
-        crowi={crowi}
-        editorOptions={editorOptions}
-        previewOptions={previewOptions}
-        onChange={(newEditorOptions, newPreviewOptions) => { // set onChange event handler
-          // set options
-          pageEditor.setEditorOptions(newEditorOptions);
-          pageEditor.setPreviewOptions(newPreviewOptions);
-          // save
-          crowi.saveEditorOptions(newEditorOptions);
-          crowi.savePreviewOptions(newPreviewOptions);
-        }}
-      />
-    </I18nextProvider>,
-    pageEditorOptionsSelectorElem,
-  );
-}
-
-// render PageStatusAlert
-let pageStatusAlert = null;
-const pageStatusAlertElem = document.getElementById('page-status-alert');
-if (pageStatusAlertElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <PageStatusAlert
-        ref={(elem) => {
-            if (pageStatusAlert == null) {
-              pageStatusAlert = elem;
-            }
-          }}
-        revisionId={pageRevisionId}
-        revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
-        hasDraftOnHackmd={hasDraftOnHackmd}
-      />
-    </I18nextProvider>,
-    pageStatusAlertElem,
-  );
-  componentInstances.pageStatusAlert = pageStatusAlert;
-}
-
 // render for admin
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {
@@ -588,7 +323,7 @@ if (customHeaderEditorElem != null) {
 const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
 if (adminRebuildSearchElem != null) {
   ReactDOM.render(
-    <AdminRebuildSearch crowi={crowi} />,
+    <AdminRebuildSearch crowi={appContainer} />,
     adminRebuildSearchElem,
   );
 }
@@ -600,103 +335,25 @@ if (adminUserGroupPageElem != null) {
   const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
 
   ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[]}>
+    <Provider inject={[]}>
+      <I18nextProvider i18n={i18n}>
         <UserGroupPage
-          crowi={crowi}
+          crowi={appContainer}
           userGroups={userGroups}
           userGroupRelations={userGroupRelations}
           isAclEnabled={isAclEnabled}
         />
-      </Provider>
-    </I18nextProvider>,
+      </I18nextProvider>
+    </Provider>,
     adminUserGroupPageElem,
   );
 }
 
-// notification from websocket
-function updatePageStatusAlert(page, user) {
-  const pageStatusAlert = componentInstances.pageStatusAlert;
-  if (pageStatusAlert != null) {
-    const revisionId = page.revision._id;
-    const revisionIdHackmdSynced = page.revisionHackmdSynced;
-    pageStatusAlert.setRevisionId(revisionId, revisionIdHackmdSynced);
-    pageStatusAlert.setLastUpdateUsername(user.name);
-  }
-}
-socket.on('page:create', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
-
-  // update PageStatusAlert
-  if (data.page.path === pagePath) {
-    updatePageStatusAlert(data.page, data.user);
-  }
-});
-socket.on('page:update', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
-
-  if (data.page.path === pagePath) {
-    // update PageStatusAlert
-    updatePageStatusAlert(data.page, data.user);
-    // update PageEditorByHackmd
-    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
-    if (pageEditorByHackmd != null) {
-      const page = data.page;
-      pageEditorByHackmd.setRevisionId(page.revision._id, page.revisionHackmdSynced);
-      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-  }
-});
-socket.on('page:delete', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
-
-  // update PageStatusAlert
-  if (data.page.path === pagePath) {
-    updatePageStatusAlert(data.page, data.user);
-  }
-});
-socket.on('page:editingWithHackmd', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
-
-  if (data.page.path === pagePath) {
-    // update PageStatusAlert
-    const pageStatusAlert = componentInstances.pageStatusAlert;
-    if (pageStatusAlert != null) {
-      pageStatusAlert.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-    // update PageEditorByHackmd
-    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
-    if (pageEditorByHackmd != null) {
-      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-  }
-});
-
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <PageHistory pageId={pageId} crowi={crowi} />
+      <PageHistory pageId={pageContainer.state.pageId} crowi={appContainer} />
     </I18nextProvider>, document.getElementById('revision-history'),
   );
 });

+ 19 - 5
src/client/js/components/LikeButton.jsx

@@ -1,7 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-export default class LikeButton extends React.Component {
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
+class LikeButton extends React.Component {
 
   constructor(props) {
     super(props);
@@ -16,16 +19,17 @@ export default class LikeButton extends React.Component {
   handleClick(event) {
     event.preventDefault();
 
+    const { appContainer } = this.props;
     const pageId = this.props.pageId;
 
     if (!this.state.isLiked) {
-      this.props.crowi.apiPost('/likes.add', { page_id: pageId })
+      appContainer.apiPost('/likes.add', { page_id: pageId })
         .then((res) => {
           this.setState({ isLiked: true });
         });
     }
     else {
-      this.props.crowi.apiPost('/likes.remove', { page_id: pageId })
+      appContainer.apiPost('/likes.remove', { page_id: pageId })
         .then((res) => {
           this.setState({ isLiked: false });
         });
@@ -33,7 +37,7 @@ export default class LikeButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.appContainer.me !== '';
   }
 
   render() {
@@ -64,9 +68,19 @@ export default class LikeButton extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const LikeButtonWrapper = (props) => {
+  return createSubscribedElement(LikeButton, props, [AppContainer]);
+};
+
 LikeButton.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pageId: PropTypes.string,
   isLiked: PropTypes.bool,
   size: PropTypes.string,
 };
+
+export default LikeButtonWrapper;

+ 196 - 0
src/client/js/components/MyDraftList/Draft.jsx

@@ -0,0 +1,196 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+import Panel from 'react-bootstrap/es/Panel';
+import Tooltip from 'react-bootstrap/es/Tooltip';
+import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
+import RevisionBody from '../Page/RevisionBody';
+
+class Draft extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+      isRendered: false,
+      isPanelExpanded: false,
+      showCopiedMessage: false,
+    };
+
+    this.growiRenderer = this.props.appContainer.getRenderer('draft');
+
+    this.changeToolTipLabel = this.changeToolTipLabel.bind(this);
+    this.expandPanelHandler = this.expandPanelHandler.bind(this);
+    this.collapsePanelHandler = this.collapsePanelHandler.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+    this.renderAccordionTitle = this.renderAccordionTitle.bind(this);
+  }
+
+  changeToolTipLabel() {
+    this.setState({ showCopiedMessage: true });
+    setTimeout(() => {
+      this.setState({ showCopiedMessage: false });
+    }, 1000);
+  }
+
+  expandPanelHandler() {
+    this.setState({ isPanelExpanded: true });
+
+    if (!this.state.isRendered) {
+      this.renderHtml();
+    }
+  }
+
+  collapsePanelHandler() {
+    this.setState({ isPanelExpanded: false });
+  }
+
+  async renderHtml() {
+    const context = {
+      markdown: this.props.markdown,
+    };
+
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
+    await interceptorManager.process('prePreProcess', context)
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML, isRendered: true });
+      });
+  }
+
+  renderAccordionTitle(isExist) {
+    const iconClass = this.state.isPanelExpanded ? 'caret-opened' : '';
+
+    return (
+      <Fragment>
+        <i className={`caret ${iconClass}`}></i>
+        <span className="mx-2">{this.props.path}</span>
+        { isExist && (
+          <span>({this.props.t('page exists')})</span>
+        ) }
+        { !isExist && (
+          <span className="label-draft label label-default">draft</span>
+        ) }
+      </Fragment>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    const copyButtonTooltip = (
+      <Tooltip id="draft-copied-tooltip">
+        { this.state.showCopiedMessage && (
+          <strong>copied!</strong>
+        ) }
+        { !this.state.showCopiedMessage && (
+          <span>{this.props.t('Copy')}</span>
+        ) }
+      </Tooltip>
+    );
+
+    return (
+      <div className="draft-list-item">
+        <Panel>
+          <Panel.Heading className="d-flex">
+            <Panel.Toggle>
+              {this.renderAccordionTitle(this.props.isExist)}
+            </Panel.Toggle>
+            <a href={this.props.path}><i className="icon icon-login"></i></a>
+            <div className="flex-grow-1"></div>
+            <div className="icon-container">
+              {this.props.isExist
+                ? null
+                : (
+                  <a
+                    href={`${this.props.path}#edit`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    data-toggle="tooltip"
+                    title={this.props.t('Edit')}
+                  >
+                    <i className="mx-2 icon-note" />
+                  </a>
+                )
+              }
+              <OverlayTrigger overlay={copyButtonTooltip} placement="top">
+                <CopyToClipboard text={this.props.markdown} onCopy={this.changeToolTipLabel}>
+                  <a
+                    className="text-center draft-copy"
+                  >
+                    <i className="mx-2 ti-clipboard" />
+                  </a>
+                </CopyToClipboard>
+              </OverlayTrigger>
+              <a
+                className="text-danger text-center"
+                data-toggle="tooltip"
+                data-placement="top"
+                title={t('Delete')}
+                onClick={() => { return this.props.clearDraft(this.props.path) }}
+              >
+                <i className="mx-2 icon-trash" />
+              </a>
+            </div>
+          </Panel.Heading>
+          <Panel.Collapse onEnter={this.expandPanelHandler} onExit={this.collapsePanelHandler}>
+            <Panel.Body>
+              {/* loading spinner */}
+              { this.state.isPanelExpanded && !this.state.isRendered && (
+                <div className="text-center">
+                  <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+                </div>
+              ) }
+              {/* contents */}
+              { this.state.isPanelExpanded && this.state.isRendered && (
+                <RevisionBody html={this.state.html} />
+              ) }
+            </Panel.Body>
+          </Panel.Collapse>
+        </Panel>
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const DraftWrapper = (props) => {
+  return createSubscribedElement(Draft, props, [AppContainer]);
+};
+
+
+Draft.propTypes = {
+  t: PropTypes.func.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  path: PropTypes.string.isRequired,
+  markdown: PropTypes.string.isRequired,
+  isExist: PropTypes.bool.isRequired,
+  clearDraft: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(DraftWrapper);

+ 55 - 24
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -1,10 +1,18 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
 import Pagination from 'react-bootstrap/lib/Pagination';
-import Draft from '../PageList/Draft';
 
-export default class MyDraftList extends React.Component {
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import EditorContainer from '../../services/EditorContainer';
+
+import Draft from './Draft';
+
+class MyDraftList extends React.Component {
 
   constructor(props) {
     super(props);
@@ -29,9 +37,9 @@ export default class MyDraftList extends React.Component {
   }
 
   async getDraftsFromLocalStorage() {
-    const draftsAsObj = JSON.parse(this.props.crowi.localStorage.getItem('draft') || '{}');
+    const draftsAsObj = this.props.editorContainer.drafts;
 
-    const res = await this.props.crowi.apiGet('/pages.exist', {
+    const res = await this.props.appContainer.apiGet('/pages.exist', {
       pages: draftsAsObj,
     });
 
@@ -49,7 +57,10 @@ export default class MyDraftList extends React.Component {
   }
 
   getCurrentDrafts(selectPageNumber) {
-    const limit = this.props.limit;
+    const { appContainer } = this.props;
+
+    const limit = appContainer.getConfig().recentCreatedLimit;
+
     const totalCount = this.state.drafts.length;
     const activePage = selectPageNumber;
     const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
@@ -74,8 +85,6 @@ export default class MyDraftList extends React.Component {
       return (
         <Draft
           key={draft.path}
-          crowi={this.props.crowi}
-          crowiOriginRenderer={this.props.crowiOriginRenderer}
           path={draft.path}
           markdown={draft.markdown}
           isExist={draft.isExist}
@@ -86,7 +95,7 @@ export default class MyDraftList extends React.Component {
   }
 
   clearDraft(path) {
-    this.props.crowi.clearDraft(path);
+    this.props.editorContainer.clearDraft(path);
 
     this.setState((prevState) => {
       return {
@@ -97,7 +106,7 @@ export default class MyDraftList extends React.Component {
   }
 
   clearAllDrafts() {
-    this.props.crowi.clearAllDrafts();
+    this.props.editorContainer.clearAllDrafts();
 
     this.setState({
       drafts: [],
@@ -204,10 +213,14 @@ export default class MyDraftList extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
+
     const draftList = this.generateDraftList(this.state.currentDrafts);
 
     const paginationItems = [];
 
+    const totalCount = this.state.drafts.length;
+
     const activePage = this.state.activePage;
     const totalPage = this.state.paginationNumbers.totalPage;
     const paginationStart = this.state.paginationNumbers.paginationStart;
@@ -222,21 +235,28 @@ export default class MyDraftList extends React.Component {
     return (
       <div className="page-list-container-create">
 
-        { draftList.length === 0
+        { totalCount === 0
           && <span>No drafts yet.</span>
         }
 
-        { draftList.length > 0
-          && (
-            <React.Fragment>
-              <button type="button" className="btn-danger mb-3" onClick={this.clearAllDrafts}>Delete All</button>
-              <div className="tab-pane m-t-30 accordion" id="draft-list">
-                {draftList}
+        { totalCount > 0 && (
+          <React.Fragment>
+            <div className="d-flex justify-content-between">
+              <h4>Total: {totalCount} drafts</h4>
+              <div className="align-self-center">
+                <button type="button" className="btn btn-sm btn-default" onClick={this.clearAllDrafts}>
+                  <i className="icon-fw icon-fire text-danger"></i>
+                  {t('Delete All')}
+                </button>
               </div>
-              <Pagination bsSize="small">{paginationItems}</Pagination>
-            </React.Fragment>
-          )
-        }
+            </div>
+
+            <div className="tab-pane m-t-30 accordion" id="draft-list">
+              {draftList}
+            </div>
+            <Pagination bsSize="small">{paginationItems}</Pagination>
+          </React.Fragment>
+        ) }
 
       </div>
     );
@@ -244,9 +264,20 @@ export default class MyDraftList extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const MyDraftListWrapper = (props) => {
+  return createSubscribedElement(MyDraftList, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 
 MyDraftList.propTypes = {
-  limit: PropTypes.number,
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
+  t: PropTypes.func.isRequired, // react-i18next
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
+
+export default withTranslation()(MyDraftListWrapper);

+ 28 - 20
src/client/js/components/Page.jsx

@@ -1,26 +1,28 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import MarkdownTable from '../models/MarkdownTable';
+
 import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
-import MarkdownTable from '../models/MarkdownTable';
 import mtu from './PageEditor/MarkdownTableUtil';
 
-export default class Page extends React.Component {
+class Page extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
-      markdown: this.props.markdown,
       currentTargetTableArea: null,
     };
 
-    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
-  }
+    this.growiRenderer = this.props.appContainer.getRenderer('page');
 
-  setMarkdown(markdown) {
-    this.setState({ markdown });
+    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
 
   /**
@@ -29,7 +31,8 @@ export default class Page extends React.Component {
    * @param endLineNumber
    */
   launchHandsontableModal(beginLineNumber, endLineNumber) {
-    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
+    const markdown = this.props.pageContainer.state.markdown;
+    const tableLines = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
     this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
@@ -37,7 +40,7 @@ export default class Page extends React.Component {
   saveHandlerForHandsontableModal(markdownTable) {
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
-      this.state.markdown,
+      this.props.pageContainer.state.markdown,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
     );
@@ -46,16 +49,12 @@ export default class Page extends React.Component {
   }
 
   render() {
-    const isMobile = this.props.crowi.isMobile;
+    const isMobile = this.props.appContainer.isMobile;
+    const { markdown } = this.props.pageContainer.state;
 
     return (
       <div className={isMobile ? 'page-mobile' : ''}>
-        <RevisionRenderer
-          crowi={this.props.crowi}
-          crowiRenderer={this.props.crowiRenderer}
-          markdown={this.state.markdown}
-          pagePath={this.props.pagePath}
-        />
+        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
         <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
       </div>
     );
@@ -63,10 +62,19 @@ export default class Page extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageWrapper = (props) => {
+  return createSubscribedElement(Page, props, [AppContainer, PageContainer]);
+};
+
+
 Page.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   onSaveWithShortcut: PropTypes.func.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
 };
+
+export default PageWrapper;

+ 19 - 6
src/client/js/components/Page/RevisionLoader.jsx

@@ -3,12 +3,16 @@ import PropTypes from 'prop-types';
 
 import { Waypoint } from 'react-waypoint';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import GrowiRenderer from '../../util/GrowiRenderer';
+import AppContainer from '../../services/AppContainer';
+
 import RevisionRenderer from './RevisionRenderer';
 
 /**
  * Load data from server and render RevisionBody component
  */
-export default class RevisionLoader extends React.Component {
+class RevisionLoader extends React.Component {
 
   constructor(props) {
     super(props);
@@ -42,7 +46,7 @@ export default class RevisionLoader extends React.Component {
     };
 
     // load data with REST API
-    this.props.crowi.apiGet('/revisions.get', requestData)
+    this.props.appContainer.apiGet('/revisions.get', requestData)
       .then((res) => {
         if (!res.ok) {
           throw new Error(res.error);
@@ -96,8 +100,7 @@ export default class RevisionLoader extends React.Component {
 
     return (
       <RevisionRenderer
-        crowi={this.props.crowi}
-        crowiRenderer={this.props.crowiRenderer}
+        growiRenderer={this.props.growiRenderer}
         pagePath={this.props.pagePath}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
@@ -107,12 +110,22 @@ export default class RevisionLoader extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionLoaderWrapper = (props) => {
+  return createSubscribedElement(RevisionLoader, props, [AppContainer]);
+};
+
 RevisionLoader.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageId: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   highlightKeywords: PropTypes.string,
 };
+
+export default RevisionLoaderWrapper;

+ 34 - 19
src/client/js/components/Page/RevisionRenderer.jsx

@@ -1,9 +1,14 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import GrowiRenderer from '../../util/GrowiRenderer';
+
 import RevisionBody from './RevisionBody';
 
-export default class RevisionRenderer extends React.Component {
+class RevisionRenderer extends React.Component {
 
   constructor(props) {
     super(props);
@@ -14,18 +19,16 @@ export default class RevisionRenderer extends React.Component {
 
     this.renderHtml = this.renderHtml.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
+  }
 
-    this.setMarkdown(this.props.markdown);
+  componentWillMount() {
+    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
   }
 
   componentWillReceiveProps(nextProps) {
     this.renderHtml(nextProps.markdown, this.props.highlightKeywords);
   }
 
-  setMarkdown(markdown) {
-    this.renderHtml(markdown, this.props.highlightKeywords);
-  }
-
   /**
    * transplanted from legacy code -- Yuki Takei
    * @param {string} body html strings
@@ -48,30 +51,32 @@ export default class RevisionRenderer extends React.Component {
     return returnBody;
   }
 
-  renderHtml(markdown, highlightKeywords) {
+  renderHtml(markdown) {
+    const { pageContainer } = this.props;
+
     const context = {
       markdown,
-      currentPagePath: this.props.pagePath,
+      currentPagePath: pageContainer.state.path,
     };
 
-    const crowiRenderer = this.props.crowiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const growiRenderer = this.props.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRender', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        context.parsedHTML = crowiRenderer.process(context.markdown);
+        context.parsedHTML = growiRenderer.process(context.markdown);
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
         // highlight
-        if (highlightKeywords != null) {
-          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
+        if (this.props.highlightKeywords != null) {
+          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, this.props.highlightKeywords);
         }
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
@@ -85,7 +90,7 @@ export default class RevisionRenderer extends React.Component {
   }
 
   render() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     return (
@@ -99,10 +104,20 @@ export default class RevisionRenderer extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionRendererWrapper = (props) => {
+  return createSubscribedElement(RevisionRenderer, props, [AppContainer, PageContainer]);
+};
+
 RevisionRenderer.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
 };
+
+export default RevisionRendererWrapper;

+ 10 - 49
src/client/js/components/Page/TagEditor.jsx

@@ -1,11 +1,13 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import * as toastr from 'toastr';
 import Button from 'react-bootstrap/es/Button';
 import Modal from 'react-bootstrap/es/Modal';
+
+import AppContainer from '../../services/AppContainer';
+
 import TagsInput from './TagsInput';
 
-class TagEditor extends React.Component {
+export default class TagEditor extends React.Component {
 
   constructor(props) {
     super(props);
@@ -13,23 +15,19 @@ class TagEditor extends React.Component {
     this.state = {
       tags: [],
       isOpenModal: false,
-      isEditorMode: null,
     };
 
     this.show = this.show.bind(this);
-    this.onTagsUpdatedByFormHandler = this.onTagsUpdatedByFormHandler.bind(this);
+    this.onTagsUpdatedByTagsInput = this.onTagsUpdatedByTagsInput.bind(this);
     this.closeModalHandler = this.closeModalHandler.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
-    this.apiSuccessHandler = this.apiSuccessHandler.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
 
   show(tags) {
-    const isEditorMode = this.props.crowi.getCrowiForJquery().getCurrentEditorMode();
-    this.setState({ isOpenModal: true, isEditorMode, tags });
+    this.setState({ tags, isOpenModal: true });
   }
 
-  onTagsUpdatedByFormHandler(tags) {
+  onTagsUpdatedByTagsInput(tags) {
     this.setState({ tags });
   }
 
@@ -38,47 +36,12 @@ class TagEditor extends React.Component {
   }
 
   async handleSubmit() {
-
-    if (!this.state.isEditorMode) {
-      try {
-        await this.props.crowi.apiPost('/tags.update', { pageId: this.props.pageId, tags: this.state.tags });
-        this.apiSuccessHandler();
-      }
-      catch (err) {
-        this.apiErrorHandler(err);
-        return;
-      }
-    }
-
     this.props.onTagsUpdated(this.state.tags);
 
     // close modal
     this.setState({ isOpenModal: false });
   }
 
-  apiSuccessHandler() {
-    toastr.success(undefined, 'updated tags successfully', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '1200',
-      extendedTimeOut: '150',
-    });
-  }
-
-  apiErrorHandler(err) {
-    toastr.error(err.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   render() {
     return (
       <Modal show={this.state.isOpenModal} onHide={this.closeModalHandler} id="editTagModal">
@@ -86,7 +49,7 @@ class TagEditor extends React.Component {
           <Modal.Title className="text-white">Edit Tags</Modal.Title>
         </Modal.Header>
         <Modal.Body>
-          <TagsInput crowi={this.props.crowi} tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByFormHandler} />
+          <TagsInput tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByTagsInput} />
         </Modal.Body>
         <Modal.Footer>
           <Button variant="primary" onClick={this.handleSubmit}>
@@ -100,9 +63,7 @@ class TagEditor extends React.Component {
 }
 
 TagEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onTagsUpdated: PropTypes.func.isRequired,
 };
-
-export default TagEditor;

+ 94 - 36
src/client/js/components/Page/TagLabels.jsx

@@ -2,6 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import EditorContainer from '../../services/EditorContainer';
+
 import TagEditor from './TagEditor';
 
 class TagLabels extends React.Component {
@@ -10,61 +17,105 @@ class TagLabels extends React.Component {
     super(props);
 
     this.state = {
-      tags: [],
+      showTagEditor: false,
     };
 
     this.showEditor = this.showEditor.bind(this);
     this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
   }
 
-  async componentWillMount() {
-    // set pageTag on button
-    const pageId = this.props.pageId;
+  /**
+   * @return tags data
+   *   1. pageContainer.state.tags if editorMode is null
+   *   2. editorContainer.state.tags if editorMode is not null
+   */
+  getEditTargetData() {
+    const { editorMode } = this.props.appContainer.state;
+    return (editorMode == null)
+      ? this.props.pageContainer.state.tags
+      : this.props.editorContainer.state.tags;
+  }
+
+  showEditor() {
+    this.tagEditor.show(this.getEditTargetData());
+  }
+
+  async tagsUpdatedHandler(tags) {
+    const { appContainer, editorContainer } = this.props;
+    const { editorMode } = appContainer.state;
+
+    // post api request and update tags
+    if (editorMode == null) {
+      const { pageContainer } = this.props;
+
+      try {
+        const { pageId } = pageContainer.state;
+        await appContainer.apiPost('/tags.update', { pageId, tags });
 
-    if (pageId) {
-      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
-      this.setState({ tags: res.tags });
-      this.props.sendTagData(res.tags);
+        // update pageContainer.state
+        pageContainer.setState({ tags });
+        editorContainer.setState({ tags });
+
+        this.apiSuccessHandler();
+      }
+      catch (err) {
+        this.apiErrorHandler(err);
+        return;
+      }
     }
-    else if (this.props.templateTagData) {
-      const templateTags = this.props.templateTagData.split(',');
-      this.setState({ tags: templateTags });
-      this.props.sendTagData(templateTags);
+    // only update tags in editorContainer
+    else {
+      editorContainer.setState({ tags });
     }
   }
 
-  showEditor() {
-    this.tagEditor.show(this.state.tags);
+  apiSuccessHandler() {
+    toastr.success(undefined, 'updated tags successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
   }
 
-  tagsUpdatedHandler(tags) {
-    this.setState({ tags });
-    this.props.sendTagData(tags);
+  apiErrorHandler(err) {
+    toastr.error(err.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
   }
 
   render() {
-    const tagElements = [];
-    const { t, pageId } = this.props;
+    const { t } = this.props;
+    const { pageId } = this.props.pageContainer.state;
 
-    for (let i = 0; i < this.state.tags.length; i++) {
-      tagElements.push(
-        <span key={`${pageId}_${i}`} className="text-muted">
+    const tags = this.getEditTargetData();
+
+    const tagElements = tags.map((tag) => {
+      return (
+        <span key={`${pageId}_${tag}`} className="text-muted">
           <i className="tag-icon icon-tag mr-1"></i>
-          <a className="tag-name mr-2" href={`/_search?q=tag:${this.state.tags[i]}`} key={i.toString()}>{this.state.tags[i]}</a>
-        </span>,
+          <a className="tag-name mr-2" href={`/_search?q=tag:${tag}`} key={`${pageId}_${tag}_link`}>{tag}</a>
+        </span>
       );
-
-    }
+    });
 
     return (
-      <div className={`tag-viewer ${this.props.pageId ? 'existed-page' : 'new-page'}`}>
-        {this.state.tags.length === 0 && (
+      <div className={`tag-viewer ${pageId ? 'existed-page' : 'new-page'}`}>
+        {tags.length === 0 && (
           <a className="btn btn-link btn-edit-tags no-tags p-0" onClick={this.showEditor}>
             { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
           </a>
         )}
         {tagElements}
-        {this.state.tags.length > 0 && (
+        {tags.length > 0 && (
           <a className="btn btn-link btn-edit-tags p-0" onClick={this.showEditor}>
             <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
           </a>
@@ -72,8 +123,8 @@ class TagLabels extends React.Component {
 
         <TagEditor
           ref={(c) => { this.tagEditor = c }}
-          crowi={this.props.crowi}
-          pageId={this.props.pageId}
+          appContainer={this.props.appContainer}
+          show={this.state.showTagEditor}
           onTagsUpdated={this.tagsUpdatedHandler}
         >
         </TagEditor>
@@ -83,12 +134,19 @@ class TagLabels extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const TagLabelsWrapper = (props) => {
+  return createSubscribedElement(TagLabels, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
+
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
-  sendTagData: PropTypes.func.isRequired,
-  templateTagData: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withTranslation()(TagLabels);
+export default withTranslation()(TagLabelsWrapper);

+ 16 - 4
src/client/js/components/Page/TagsInput.jsx

@@ -2,6 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -11,7 +14,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
  * @extends {React.Component}
  */
 
-export default class TagsInput extends React.Component {
+class TagsInput extends React.Component {
 
   constructor(props) {
     super(props);
@@ -22,7 +25,6 @@ export default class TagsInput extends React.Component {
       selected: this.props.tags,
       defaultPageTags: this.props.tags,
     };
-    this.crowi = this.props.crowi;
 
     this.handleChange = this.handleChange.bind(this);
     this.handleSearch = this.handleSearch.bind(this);
@@ -42,7 +44,7 @@ export default class TagsInput extends React.Component {
 
   async handleSearch(query) {
     this.setState({ isLoading: true });
-    const res = await this.crowi.apiGet('/tags.search', { q: query });
+    const res = await this.props.appContainer.apiGet('/tags.search', { q: query });
     res.tags.unshift(query); // selectable new tag whose name equals query
     this.setState({
       resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
@@ -87,11 +89,21 @@ export default class TagsInput extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const TagsInputWrapper = (props) => {
+  return createSubscribedElement(TagsInput, props, [AppContainer]);
+};
+
 TagsInput.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   tags: PropTypes.array.isRequired,
   onTagsUpdated: PropTypes.func.isRequired,
 };
 
 TagsInput.defaultProps = {
 };
+
+export default TagsInputWrapper;

+ 23 - 9
src/client/js/components/PageAttachment.js

@@ -4,8 +4,11 @@ import PropTypes from 'prop-types';
 
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
 
-export default class PageAttachment extends React.Component {
+class PageAttachment extends React.Component {
 
   constructor(props) {
     super(props);
@@ -23,13 +26,13 @@ export default class PageAttachment extends React.Component {
   }
 
   componentDidMount() {
-    const pageId = this.props.pageId;
+    const { pageId } = this.props.pageContainer.state;
 
     if (!pageId) {
       return;
     }
 
-    this.props.crowi.apiGet('/attachments.list', { page_id: pageId })
+    this.props.appContainer.apiGet('/attachments.list', { page_id: pageId })
       .then((res) => {
         const attachments = res.attachments;
         const inUse = {};
@@ -46,7 +49,9 @@ export default class PageAttachment extends React.Component {
   }
 
   checkIfFileInUse(attachment) {
-    if (this.props.markdown.match(attachment.filePathProxied)) {
+    const { markdown } = this.pageContainer.state;
+
+    if (markdown.match(attachment.filePathProxied)) {
       return true;
     }
     return false;
@@ -64,7 +69,7 @@ export default class PageAttachment extends React.Component {
       deleting: true,
     });
 
-    this.props.crowi.apiPost('/attachments.remove', { attachment_id: attachmentId })
+    this.props.appContainer.apiPost('/attachments.remove', { attachment_id: attachmentId })
       .then((res) => {
         this.setState({
           attachments: this.state.attachments.filter((at) => {
@@ -84,7 +89,7 @@ export default class PageAttachment extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.appContainer.me !== '';
   }
 
   render() {
@@ -133,8 +138,17 @@ export default class PageAttachment extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageAttachmentWrapper = (props) => {
+  return createSubscribedElement(PageAttachment, props, [AppContainer, PageContainer]);
+};
+
+
 PageAttachment.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pageId: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
+
+export default PageAttachmentWrapper;

+ 147 - 30
src/client/js/components/PageComment/Comment.jsx

@@ -3,9 +3,11 @@ import PropTypes from 'prop-types';
 
 import dateFnsFormat from 'date-fns/format';
 
-import RevisionBody from '../Page/RevisionBody';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
 
-import ReactUtils from '../ReactUtils';
+import { createSubscribedElement } from '../UnstatedUtils';
+import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 
@@ -17,13 +19,14 @@ import Username from '../User/Username';
  * @class Comment
  * @extends {React.Component}
  */
-export default class Comment extends React.Component {
+class Comment extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
       html: '',
+      isLayoutTypeGrowi: false,
     };
 
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
@@ -31,11 +34,18 @@ export default class Comment extends React.Component {
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
+    this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
   }
 
   componentWillMount() {
     this.renderHtml(this.props.comment.comment);
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
 
   componentWillReceiveProps(nextProps) {
@@ -48,11 +58,11 @@ export default class Comment extends React.Component {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.currentUserId;
+    return this.props.comment.creator.username === this.props.appContainer.me;
   }
 
   isCurrentRevision() {
-    return this.props.comment.revision === this.props.currentRevisionId;
+    return this.props.comment.revision === this.props.pageContainer.state.revisionId;
   }
 
   getRootClassName() {
@@ -69,8 +79,12 @@ export default class Comment extends React.Component {
     this.props.deleteBtnClicked(this.props.comment);
   }
 
+  renderText(comment) {
+    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
+  }
+
   renderRevisionBody() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
     return (
       <RevisionBody
@@ -82,26 +96,34 @@ export default class Comment extends React.Component {
     );
   }
 
+  toggleOlderReplies() {
+    this.setState((prevState) => {
+      return {
+        showOlderReplies: !prevState.showOlderReplies,
+      };
+    });
+  }
+
   renderHtml(markdown) {
     const context = {
       markdown,
     };
 
-    const crowiRenderer = this.props.crowiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const growiRenderer = this.props.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderComment', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        const parsedHTML = crowiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown);
         context.parsedHTML = parsedHTML;
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
@@ -113,6 +135,68 @@ export default class Comment extends React.Component {
 
   }
 
+  renderReplies() {
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    let replyList = this.props.replyList;
+    if (!isLayoutTypeGrowi) {
+      replyList = replyList.slice().reverse();
+    }
+
+    const areThereHiddenReplies = replyList.length > 2;
+
+    const iconForOlder = <i className="icon-options-vertical"></i>;
+    const toggleOlder = areThereHiddenReplies
+      ? (
+        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
+          {iconForOlder} Read More
+        </a>
+      )
+      : <div></div>;
+
+    const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
+    const hiddenReplies = replyList.slice(0, replyList.length - 2);
+
+    const toggleElements = hiddenReplies.map((reply) => {
+      return (
+        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+          <CommentWrapper
+            comment={reply}
+            deleteBtnClicked={this.props.deleteBtnClicked}
+            growiRenderer={this.props.growiRenderer}
+            replyList={[]}
+          />
+        </div>
+      );
+    });
+
+    const toggleBlock = (
+      <div className="page-comments-list-older collapse out" id="page-comments-list-older">
+        {toggleElements}
+      </div>
+    );
+
+    const shownBlock = shownReplies.map((reply) => {
+      return (
+        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+          <CommentWrapper
+            comment={reply}
+            deleteBtnClicked={this.props.deleteBtnClicked}
+            growiRenderer={this.props.growiRenderer}
+            replyList={[]}
+          />
+        </div>
+      );
+    });
+
+    return (
+      <div>
+        {toggleBlock}
+        {toggleOlder}
+        {shownBlock}
+      </div>
+    );
+  }
+
   render() {
     const comment = this.props.comment;
     const creator = comment.creator;
@@ -120,27 +204,50 @@ export default class Comment extends React.Component {
 
     const rootClassName = this.getRootClassName();
     const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
-    const commentBody = isMarkdown ? this.renderRevisionBody() : ReactUtils.nl2br(comment.comment);
+    const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
+    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
+
+    let isNewer;
+    if (comment.revision === revisionId) {
+      isNewer = 'page-comments-list-current';
+    }
+    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
+      isNewer = 'page-comments-list-newer';
+    }
+    else {
+      isNewer = 'page-comments-list-older';
+    }
+
+
     return (
-      <div className={rootClassName}>
-        <UserPicture user={creator} />
-        <div className="page-comment-main">
-          <div className="page-comment-creator">
-            <Username user={creator} />
+      <div>
+        <div className={isNewer}>
+          <div className={rootClassName}>
+            <UserPicture user={creator} />
+            <div className="page-comment-main">
+              <div className="page-comment-creator">
+                <Username user={creator} />
+              </div>
+              <div className="page-comment-body">{commentBody}</div>
+              <div className="page-comment-meta">
+                {commentDate}&nbsp;
+                <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
+              </div>
+              <div className="page-comment-control">
+                <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+                  <i className="ti-close"></i>
+                </button>
+              </div>
+            </div>
           </div>
-          <div className="page-comment-body">{commentBody}</div>
-          <div className="page-comment-meta">
-            {commentDate}&nbsp;
-            <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
-          </div>
-          <div className="page-comment-control">
-            <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
-              <i className="ti-close"></i>
-            </button>
+        </div>
+        <div className="container-fluid">
+          <div className="row">
+            {this.renderReplies()}
           </div>
         </div>
       </div>
@@ -149,11 +256,21 @@ export default class Comment extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const CommentWrapper = (props) => {
+  return createSubscribedElement(Comment, props, [AppContainer, PageContainer]);
+};
+
 Comment.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   comment: PropTypes.object.isRequired,
-  currentRevisionId: PropTypes.string.isRequired,
-  currentUserId: PropTypes.string.isRequired,
+  growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  replyList: PropTypes.array,
 };
+
+export default CommentWrapper;

+ 348 - 0
src/client/js/components/PageComment/CommentEditor.jsx

@@ -0,0 +1,348 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Button from 'react-bootstrap/es/Button';
+import Tab from 'react-bootstrap/es/Tab';
+import Tabs from 'react-bootstrap/es/Tabs';
+import * as toastr from 'toastr';
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import CommentContainer from '../../services/CommentContainer';
+import EditorContainer from '../../services/EditorContainer';
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import UserPicture from '../User/UserPicture';
+import Editor from '../PageEditor/Editor';
+import SlackNotification from '../SlackNotification';
+
+import CommentPreview from './CommentPreview';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {React.Component}
+ */
+
+class CommentEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const config = this.props.appContainer.getConfig();
+    const isUploadable = config.upload.image || config.upload.file;
+    const isUploadableFile = config.upload.file;
+
+    this.state = {
+      isLayoutTypeGrowi: false,
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+      isUploadable,
+      isUploadableFile,
+      errorMessage: undefined,
+      hasSlackConfig: config.hasSlackConfig,
+    };
+
+    this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
+
+    this.postHandler = this.postHandler.bind(this);
+    this.uploadHandler = this.uploadHandler.bind(this);
+
+    this.renderHtml = this.renderHtml.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
+    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+    this.toggleEditor = this.toggleEditor.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  updateState(value) {
+    this.setState({ comment: value });
+  }
+
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({ isMarkdown: value });
+    // changeMode
+    this.editor.setGfmMode(value);
+  }
+
+  handleSelect(key) {
+    this.setState({ key });
+    this.renderHtml(this.state.comment);
+  }
+
+  onSlackEnabledFlagChange(isSlackEnabled) {
+    this.props.commentContainer.setState({ isSlackEnabled });
+  }
+
+  onSlackChannelsChange(slackChannels) {
+    this.props.commentContainer.setState({ slackChannels });
+  }
+
+  toggleEditor() {
+    this.props.commentButtonClickedHandler(this.props.replyTo);
+  }
+
+  /**
+   * Post comment with CommentContainer and update state
+   */
+  postHandler(event) {
+    if (event != null) {
+      event.preventDefault();
+    }
+
+    const { commentContainer } = this.props;
+
+    this.props.commentContainer.postComment(
+      this.state.comment,
+      this.state.isMarkdown,
+      this.props.replyTo,
+      commentContainer.state.isSlackEnabled,
+      commentContainer.state.slackChannels,
+    )
+      .then((res) => {
+        this.setState({
+          comment: '',
+          isMarkdown: true,
+          html: '',
+          key: 1,
+          errorMessage: undefined,
+        });
+        // reset value
+        this.editor.setValue('');
+        this.toggleEditor();
+      })
+      .catch((err) => {
+        const errorMessage = err.message || 'An unknown error occured when posting comment';
+        this.setState({ errorMessage });
+      });
+  }
+
+  uploadHandler(file) {
+    this.props.commentContainer.uploadAttachment(file)
+      .then((res) => {
+        const attachment = res.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${attachment.filePathProxied})`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+        this.editor.insertText(insertText);
+      })
+      .catch(this.apiErrorHandler)
+      // finally
+      .then(() => {
+        this.editor.terminateUploadingState();
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  getCommentHtml() {
+    return (
+      <CommentPreview
+        inputRef={(el) => { this.previewElement = el }}
+        html={this.state.html}
+      />
+    );
+  }
+
+  renderHtml(markdown) {
+    const context = {
+      markdown,
+    };
+
+    const { growiRenderer } = this.props;
+    const interceptorManager = this.props.appContainer.interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', context)
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
+  }
+
+  generateInnerHtml(html) {
+    return { __html: html };
+  }
+
+  render() {
+    const { appContainer, commentContainer } = this.props;
+    const username = appContainer.me;
+    const user = appContainer.findUser(username);
+    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
+    const emojiStrategy = appContainer.getEmojiStrategy();
+
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+
+    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
+    const submitButton = (
+      <Button
+        bsStyle="primary"
+        className="fcbtn btn btn-primary btn-outline btn-rounded btn-1b"
+        onClick={this.postHandler}
+      >
+        Comment
+      </Button>
+    );
+
+    return (
+      <div className="form page-comment-form">
+
+        { username
+          && (
+          <div className="comment-form">
+            { isLayoutTypeGrowi
+              && (
+              <div className="comment-form-user">
+                <UserPicture user={user} />
+              </div>
+              )
+            }
+            <div className="comment-form-main">
+              <div className="comment-write">
+                <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                  <Tab eventKey={1} title="Write">
+                    <Editor
+                      ref={(c) => { this.editor = c }}
+                      value={this.state.comment}
+                      isGfmMode={this.state.isMarkdown}
+                      lineNumbers={false}
+                      isMobile={appContainer.isMobile}
+                      isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
+                      isUploadableFile={this.state.isUploadableFile}
+                      emojiStrategy={emojiStrategy}
+                      onChange={this.updateState}
+                      onUpload={this.uploadHandler}
+                      onCtrlEnter={this.postHandler}
+                    />
+                  </Tab>
+                  { this.state.isMarkdown
+                    && (
+                    <Tab eventKey={2} title="Preview">
+                      <div className="comment-form-preview">
+                        {commentPreview}
+                      </div>
+                    </Tab>
+                    )
+                  }
+                </Tabs>
+              </div>
+              <div className="comment-submit">
+                <div className="d-flex">
+                  <label style={{ flex: 1 }}>
+                    { isLayoutTypeGrowi && this.state.key === 1
+                      && (
+                      <span>
+                        <input
+                          type="checkbox"
+                          id="comment-form-is-markdown"
+                          name="isMarkdown"
+                          checked={this.state.isMarkdown}
+                          value="1"
+                          onChange={this.updateStateCheckbox}
+                        />
+                        <span className="ml-2">Markdown</span>
+                      </span>
+                      )
+                  }
+                  </label>
+                  <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                  { this.state.hasSlackConfig
+                    && (
+                    <div className="form-inline align-self-center mr-md-2">
+                      <SlackNotification
+                        isSlackEnabled={commentContainer.state.isSlackEnabled}
+                        slackChannels={commentContainer.state.slackChannels}
+                        onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                        onChannelChange={this.onSlackChannelsChange}
+                      />
+                    </div>
+                    )
+                  }
+                  <div>
+                    <Button bsStyle="danger" className="fcbtn btn btn-xs btn-danger btn-outline btn-rounded" onClick={this.toggleEditor}>
+                      Cancel
+                    </Button>
+                  </div>
+                  &nbsp;&nbsp;&nbsp;&nbsp;
+                  <div className="hidden-xs">{submitButton}</div>
+                </div>
+                <div className="visible-xs mt-2">
+                  <div className="d-flex justify-content-end">
+                    { this.state.errorMessage && errorMessage }
+                    <div>{submitButton}</div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          )
+        }
+
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorWrapper = (props) => {
+  return createSubscribedElement(CommentEditor, props, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
+};
+
+CommentEditor.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
+  replyTo: PropTypes.string,
+  commentButtonClickedHandler: PropTypes.func.isRequired,
+};
+
+export default CommentEditorWrapper;

+ 100 - 0
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import UserPicture from '../User/UserPicture';
+
+import CommentEditor from './CommentEditor';
+
+class CommentEditorLazyRenderer extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isEditorShown: false,
+      isLayoutTypeGrowi: false,
+    };
+
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
+
+    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  showCommentFormBtnClickHandler() {
+    this.setState({ isEditorShown: !this.state.isEditorShown });
+  }
+
+  render() {
+    const { appContainer } = this.props;
+    const username = appContainer.me;
+    const user = appContainer.findUser(username);
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    return (
+      <React.Fragment>
+        { !this.state.isEditorShown
+          && (
+          <div className="form page-comment-form">
+            { username
+              && (
+                <div className="comment-form">
+                  { isLayoutTypeGrowi
+                  && (
+                    <div className="comment-form-user">
+                      <UserPicture user={user} />
+                    </div>
+                  )
+                  }
+                  <div className="comment-form-main">
+                    <button
+                      type="button"
+                      className={`btn btn-lg ${this.state.isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
+                      onClick={this.showCommentFormBtnClickHandler}
+                    >
+                      <i className="icon-bubble"></i> Add Comment
+                    </button>
+                  </div>
+                </div>
+              )
+            }
+          </div>
+          )
+        }
+        { this.state.isEditorShown
+          && (
+          <CommentEditor
+            growiRenderer={this.growiRenderer}
+            replyTo={undefined}
+            commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
+          >
+          </CommentEditor>
+)
+        }
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorLazyRendererWrapper = (props) => {
+  return createSubscribedElement(CommentEditorLazyRenderer, props, [AppContainer]);
+};
+
+CommentEditorLazyRenderer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default CommentEditorLazyRendererWrapper;

+ 0 - 381
src/client/js/components/PageComment/CommentForm.jsx

@@ -1,381 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Button from 'react-bootstrap/es/Button';
-import Tab from 'react-bootstrap/es/Tab';
-import Tabs from 'react-bootstrap/es/Tabs';
-import * as toastr from 'toastr';
-import UserPicture from '../User/UserPicture';
-import ReactUtils from '../ReactUtils';
-
-import GrowiRenderer from '../../util/GrowiRenderer';
-
-import Editor from '../PageEditor/Editor';
-import CommentPreview from './CommentPreview';
-import SlackNotification from '../SlackNotification';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class Comment
- * @extends {React.Component}
- */
-
-export default class CommentForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.crowi.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-
-    this.state = {
-      isLayoutTypeGrowi: false,
-      isFormShown: false,
-      comment: '',
-      isMarkdown: true,
-      html: '',
-      key: 1,
-      isUploadable,
-      isUploadableFile,
-      errorMessage: undefined,
-      hasSlackConfig: config.hasSlackConfig,
-      isSlackEnabled: false,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-    this.postComment = this.postComment.bind(this);
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
-    this.onUpload = this.onUpload.bind(this);
-    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
-
-    const layoutType = this.props.crowi.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
-  updateState(value) {
-    this.setState({ comment: value });
-  }
-
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isMarkdown: value });
-    // changeMode
-    this.editor.setGfmMode(value);
-  }
-
-  handleSelect(key) {
-    this.setState({ key });
-    this.renderHtml(this.state.comment);
-  }
-
-  onSlackEnabledFlagChange(value) {
-    this.setState({ isSlackEnabled: value });
-  }
-
-  onSlackChannelsChange(value) {
-    this.setState({ slackChannels: value });
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  postComment(event) {
-    if (event != null) {
-      event.preventDefault();
-    }
-
-    this.props.crowi.apiPost('/comments.add', {
-      commentForm: {
-        comment: this.state.comment,
-        _csrf: this.props.crowi.csrfToken,
-        page_id: this.props.pageId,
-        revision_id: this.props.revisionId,
-        is_markdown: this.state.isMarkdown,
-      },
-      slackNotificationForm: {
-        isSlackEnabled: this.state.isSlackEnabled,
-        slackChannels: this.state.slackChannels,
-      },
-    })
-      .then((res) => {
-        if (this.props.onPostComplete != null) {
-          this.props.onPostComplete(res.comment);
-        }
-        this.setState({
-          comment: '',
-          isMarkdown: true,
-          html: '',
-          key: 1,
-          errorMessage: undefined,
-          isSlackEnabled: false,
-        });
-        // reset value
-        this.editor.setValue('');
-      })
-      .catch((err) => {
-        const errorMessage = err.message || 'An unknown error occured when posting comment';
-        this.setState({ errorMessage });
-      });
-  }
-
-  getCommentHtml() {
-    return (
-      <CommentPreview
-        inputRef={(el) => { this.previewElement = el }}
-        html={this.state.html}
-      />
-    );
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
-    interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }
-
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  onUpload(file) {
-    const endpoint = '/attachments.add';
-
-    // create a FromData instance
-    const formData = new FormData();
-    formData.append('_csrf', this.props.crowi.csrfToken);
-    formData.append('file', file);
-    formData.append('path', this.props.pagePath);
-    formData.append('page_id', this.props.pageId || 0);
-
-    // post
-    this.props.crowi.apiPost(endpoint, formData)
-      .then((res) => {
-        const attachment = res.attachment;
-        const fileName = attachment.originalName;
-
-        let insertText = `[${fileName}](${attachment.filePathProxied})`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
-        this.editor.insertText(insertText);
-      })
-      .catch(this.apiErrorHandler)
-      // finally
-      .then(() => {
-        this.editor.terminateUploadingState();
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  showCommentFormBtnClickHandler() {
-    this.setState({ isFormShown: true });
-  }
-
-  renderControls() {
-
-  }
-
-  render() {
-    const crowi = this.props.crowi;
-    const username = crowi.me;
-    const user = crowi.findUser(username);
-    const comment = this.state.comment;
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
-
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
-
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
-    const submitButton = (
-      <Button type="submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
-        Comment
-      </Button>
-    );
-
-    return (
-      <div>
-
-        <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
-          { username
-            && (
-            <div className="comment-form">
-              { isLayoutTypeGrowi
-                && (
-                <div className="comment-form-user">
-                  <UserPicture user={user} />
-                </div>
-                )
-              }
-              <div className="comment-form-main">
-                {/* Add Comment Button */}
-                { !this.state.isFormShown
-                  && (
-                  <button
-                    type="button"
-                    className={`btn btn-lg ${isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
-                    onClick={this.showCommentFormBtnClickHandler}
-                  >
-                    <i className="icon-bubble"></i> Add Comment
-                  </button>
-                  )
-                }
-                {/* Editor */}
-                { this.state.isFormShown
-                  && (
-                  <React.Fragment>
-                    <div className="comment-write">
-                      <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
-                        <Tab eventKey={1} title="Write">
-                          <Editor
-                            ref={(c) => { this.editor = c }}
-                            value={this.state.comment}
-                            isGfmMode={this.state.isMarkdown}
-                            editorOptions={this.props.editorOptions}
-                            lineNumbers={false}
-                            isMobile={this.props.crowi.isMobile}
-                            isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
-                            isUploadableFile={this.state.isUploadableFile}
-                            emojiStrategy={emojiStrategy}
-                            onChange={this.updateState}
-                            onUpload={this.onUpload}
-                            onCtrlEnter={this.postComment}
-                          />
-                        </Tab>
-                        { this.state.isMarkdown
-                          && (
-                          <Tab eventKey={2} title="Preview">
-                            <div className="comment-form-preview">
-                              {commentPreview}
-                            </div>
-                          </Tab>
-                          )
-                        }
-                      </Tabs>
-                    </div>
-                    <div className="comment-submit">
-                      <div className="d-flex">
-                        <label style={{ flex: 1 }}>
-                          { isLayoutTypeGrowi && this.state.key === 1
-                            && (
-                            <span>
-                              <input
-                                type="checkbox"
-                                id="comment-form-is-markdown"
-                                name="isMarkdown"
-                                checked={this.state.isMarkdown}
-                                value="1"
-                                onChange={this.updateStateCheckbox}
-                              />
-                              <span className="ml-2">Markdown</span>
-                            </span>
-                            )
-                        }
-                        </label>
-                        <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
-                        { this.state.hasSlackConfig
-                          && (
-                          <div className="form-inline align-self-center mr-md-2">
-                            <SlackNotification
-                              isSlackEnabled={this.state.isSlackEnabled}
-                              slackChannels={this.state.slackChannels}
-                              onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                              onChannelChange={this.onSlackChannelsChange}
-                            />
-                          </div>
-                          )
-                        }
-                        <div className="hidden-xs">{submitButton}</div>
-                      </div>
-                      <div className="visible-xs mt-2">
-                        <div className="d-flex justify-content-end">
-                          { this.state.errorMessage && errorMessage }
-                          <div>{submitButton}</div>
-                        </div>
-                      </div>
-                    </div>
-                  </React.Fragment>
-                  )
-                }
-              </div>
-            </div>
-            )
-          }
-        </form>
-
-      </div>
-    );
-  }
-
-}
-
-CommentForm.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  onPostComplete: PropTypes.func,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
-};
-CommentForm.defaultProps = {
-  editorOptions: {},
-};

+ 1 - 2
src/client/js/components/PageComment/DeleteCommentModal.jsx

@@ -6,7 +6,6 @@ import Modal from 'react-bootstrap/es/Modal';
 
 import dateFnsFormat from 'date-fns/format';
 
-import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 
@@ -33,7 +32,7 @@ export default class DeleteCommentModal extends React.Component {
     if (commentBody.length > DeleteCommentModal.OMIT_BODY_THRES) { // omit
       commentBody = `${commentBody.substr(0, DeleteCommentModal.OMIT_BODY_THRES)}...`;
     }
-    commentBody = ReactUtils.nl2br(commentBody);
+    commentBody = <span style={{ whiteSpace: 'pre-wrap' }}>{commentBody}</span>;
 
     return (
       <Modal show={this.props.isShown} onHide={this.props.cancel} className="page-comment-delete-modal">

+ 0 - 256
src/client/js/components/PageComments.js

@@ -1,256 +0,0 @@
-/* eslint-disable react/no-access-state-in-setstate */
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import GrowiRenderer from '../util/GrowiRenderer';
-
-import Comment from './PageComment/Comment';
-import DeleteCommentModal from './PageComment/DeleteCommentModal';
-
-/**
- * Load data of comments and render the list of <Comment />
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageComments
- * @extends {React.Component}
- */
-export default class PageComments extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      // desc order array
-      comments: [],
-
-      isLayoutTypeGrowi: false,
-
-      // for deleting comment
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
-    this.init = this.init.bind(this);
-    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
-    this.deleteComment = this.deleteComment.bind(this);
-    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-    this.retrieveData = this.retrieveData.bind(this);
-  }
-
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
-
-    const layoutType = this.props.crowi.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-
-    this.retrieveData();
-  }
-
-  /**
-   * Load data of comments and store them in state
-   */
-  retrieveData() {
-    // get data (desc order array)
-    this.props.crowi.apiGet('/comments.get', { page_id: this.props.pageId })
-      .then((res) => {
-        if (res.ok) {
-          this.setState({ comments: res.comments });
-        }
-      });
-  }
-
-  confirmToDeleteComment(comment) {
-    this.setState({ commentToDelete: comment });
-    this.showDeleteConfirmModal();
-  }
-
-  deleteComment() {
-    const comment = this.state.commentToDelete;
-
-    this.props.crowi.apiPost('/comments.remove', { comment_id: comment._id })
-      .then((res) => {
-        if (res.ok) {
-          this.findAndSplice(comment);
-        }
-        this.closeDeleteConfirmModal();
-      })
-      .catch((err) => {
-        this.setState({ errorMessageForDeleting: err.message });
-      });
-  }
-
-  findAndSplice(comment) {
-    const comments = this.state.comments;
-
-    const index = comments.indexOf(comment);
-    if (index < 0) {
-      return;
-    }
-    comments.splice(index, 1);
-
-    this.setState({ comments });
-  }
-
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModal() {
-    this.setState({
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    });
-  }
-
-  /**
-   * generate Elements of Comment
-   *
-   * @param {any} comments Array of Comment Model Obj
-   *
-   * @memberOf PageComments
-   */
-  generateCommentElements(comments) {
-    return comments.map((comment) => {
-      return (
-        <Comment
-          key={comment._id}
-          comment={comment}
-          currentUserId={this.props.crowi.me}
-          currentRevisionId={this.props.revisionId}
-          deleteBtnClicked={this.confirmToDeleteComment}
-          crowi={this.props.crowi}
-          crowiRenderer={this.growiRenderer}
-        />
-      );
-    });
-  }
-
-  render() {
-    const currentComments = [];
-    const newerComments = [];
-    const olderComments = [];
-
-    let comments = this.state.comments;
-    if (this.state.isLayoutTypeGrowi) {
-      // replace with asc order array
-      comments = comments.slice().reverse(); // non-destructive reverse
-    }
-
-    // divide by revisionId and createdAt
-    const revisionId = this.props.revisionId;
-    const revisionCreatedAt = this.props.revisionCreatedAt;
-    comments.forEach((comment) => {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (comment.revision == revisionId) {
-        currentComments.push(comment);
-      }
-      else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-        newerComments.push(comment);
-      }
-      else {
-        olderComments.push(comment);
-      }
-    });
-
-    // generate elements
-    const currentElements = this.generateCommentElements(currentComments);
-    const newerElements = this.generateCommentElements(newerComments);
-    const olderElements = this.generateCommentElements(olderComments);
-    // generate blocks
-    const currentBlock = (
-      <div className="page-comments-list-current" id="page-comments-list-current">
-        {currentElements}
-      </div>
-    );
-    const newerBlock = (
-      <div className="page-comments-list-newer collapse in" id="page-comments-list-newer">
-        {newerElements}
-      </div>
-    );
-    const olderBlock = (
-      <div className="page-comments-list-older collapse in" id="page-comments-list-older">
-        {olderElements}
-      </div>
-    );
-
-    // generate toggle elements
-    const iconForNewer = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-down"></i>
-      : <i className="fa fa-angle-double-up"></i>;
-    const toggleNewer = (newerElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer">
-          {iconForNewer} Comments for Newer Revision {iconForNewer}
-        </a>
-      );
-    const iconForOlder = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-up"></i>
-      : <i className="fa fa-angle-double-down"></i>;
-    const toggleOlder = (olderElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
-          {iconForOlder} Comments for Older Revision {iconForOlder}
-        </a>
-      );
-
-    // layout blocks
-    const commentsElements = (this.state.isLayoutTypeGrowi)
-      ? (
-        <div>
-          {olderBlock}
-          {toggleOlder}
-          {currentBlock}
-          {toggleNewer}
-          {newerBlock}
-        </div>
-      )
-      : (
-        <div>
-          {newerBlock}
-          {toggleNewer}
-          {currentBlock}
-          {toggleOlder}
-          {olderBlock}
-        </div>
-      );
-
-    return (
-      <div>
-        {commentsElements}
-
-        <DeleteCommentModal
-          isShown={this.state.isDeleteConfirmModalShown}
-          comment={this.state.commentToDelete}
-          errorMessage={this.state.errorMessageForDeleting}
-          cancel={this.closeDeleteConfirmModal}
-          confirmedToDelete={this.deleteComment}
-        />
-      </div>
-    );
-  }
-
-}
-
-PageComments.propTypes = {
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  revisionCreatedAt: PropTypes.number,
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-};

+ 254 - 0
src/client/js/components/PageComments.jsx

@@ -0,0 +1,254 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Button from 'react-bootstrap/es/Button';
+
+import { withTranslation } from 'react-i18next';
+
+import AppContainer from '../services/AppContainer';
+import CommentContainer from '../services/CommentContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
+import CommentEditor from './PageComment/CommentEditor';
+
+import Comment from './PageComment/Comment';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
+import PageContainer from '../services/PageContainer';
+
+
+/**
+ * Load data of comments and render the list of <Comment />
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageComments
+ * @extends {React.Component}
+ */
+class PageComments extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isLayoutTypeGrowi: false,
+
+      // for deleting comment
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+
+      showEditorIds: new Set(),
+    };
+
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
+
+    this.init = this.init.bind(this);
+    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
+    this.deleteComment = this.deleteComment.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+    this.replyButtonClickedHandler = this.replyButtonClickedHandler.bind(this);
+    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    if (!this.props.pageContainer.state.pageId) {
+      return;
+    }
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+
+    this.props.commentContainer.retrieveComments();
+  }
+
+  confirmToDeleteComment(comment) {
+    this.setState({ commentToDelete: comment });
+    this.showDeleteConfirmModal();
+  }
+
+  deleteComment() {
+    const comment = this.state.commentToDelete;
+    const comments = this.props.commentContainer.state.comments;
+    comments.forEach((reply) => {
+      if (reply.replyTo === comment._id) {
+        this.props.commentContainer.deleteComment(reply);
+      }
+    });
+
+    this.props.commentContainer.deleteComment(comment)
+      .then(() => {
+        this.closeDeleteConfirmModal();
+      })
+      .catch((err) => {
+        this.setState({ errorMessageForDeleting: err.message });
+      });
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    });
+  }
+
+  replyButtonClickedHandler(commentId) {
+    const ids = this.state.showEditorIds.add(commentId);
+    this.setState({ showEditorIds: ids });
+  }
+
+  commentButtonClickedHandler(commentId) {
+    this.setState((prevState) => {
+      prevState.showEditorIds.delete(commentId);
+      return {
+        showEditorIds: prevState.showEditorIds,
+      };
+    });
+  }
+
+  // adds replies to specific comment object
+  addRepliesToComments(comment, replies) {
+    const replyList = [];
+    replies.forEach((reply) => {
+      if (reply.replyTo === comment._id) {
+        replyList.push(reply);
+      }
+    });
+    return replyList;
+  }
+
+  /**
+   * generate Elements of Comment
+   *
+   * @param {any} comments Array of Comment Model Obj
+   *
+   * @memberOf PageComments
+   */
+  generateCommentElements(comments, replies) {
+    return comments.map((comment) => {
+
+      const commentId = comment._id;
+      const showEditor = this.state.showEditorIds.has(commentId);
+      const username = this.props.appContainer.me;
+
+      const replyList = this.addRepliesToComments(comment, replies);
+
+      return (
+        <div key={commentId}>
+          <Comment
+            comment={comment}
+            deleteBtnClicked={this.confirmToDeleteComment}
+            growiRenderer={this.growiRenderer}
+            replyList={replyList}
+          />
+          <div className="container-fluid">
+            <div className="row">
+              <div className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+                { !showEditor && (
+                  <div>
+                    { username
+                    && (
+                      <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
+                        <Button
+                          bsStyle="primary"
+                          className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b"
+                          onClick={() => { return this.replyButtonClickedHandler(commentId) }}
+                        >
+                          <i className="icon-bubble"></i> Reply
+                        </Button>
+                      </div>
+                    )
+                  }
+                  </div>
+                )}
+                { showEditor && (
+                  <CommentEditor
+                    growiRenderer={this.growiRenderer}
+                    replyTo={commentId}
+                    commentButtonClickedHandler={this.commentButtonClickedHandler}
+                  />
+                )}
+              </div>
+            </div>
+          </div>
+          <br />
+        </div>
+      );
+    });
+  }
+
+  render() {
+    const currentComments = [];
+    const currentReplies = [];
+
+    let comments = this.props.commentContainer.state.comments;
+    if (this.state.isLayoutTypeGrowi) {
+      // replace with asc order array
+      comments = comments.slice().reverse(); // non-destructive reverse
+    }
+
+    comments.forEach((comment) => {
+      if (comment.replyTo === undefined) {
+      // comment is not a reply
+        currentComments.push(comment);
+      }
+      else {
+      // comment is a reply
+        currentReplies.push(comment);
+      }
+    });
+
+    // generate elements
+    const currentElements = this.generateCommentElements(currentComments, currentReplies);
+
+    // generate blocks
+    const currentBlock = (
+      <div className="page-comments-list-current" id="page-comments-list-current">
+        {currentElements}
+      </div>
+    );
+
+    // layout blocks
+    const commentsElements = (<div>{currentBlock}</div>);
+
+    return (
+      <div>
+        {commentsElements}
+
+        <DeleteCommentModal
+          isShown={this.state.isDeleteConfirmModalShown}
+          comment={this.state.commentToDelete}
+          errorMessage={this.state.errorMessageForDeleting}
+          cancel={this.closeDeleteConfirmModal}
+          confirmedToDelete={this.deleteComment}
+        />
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageCommentsWrapper = (props) => {
+  return createSubscribedElement(PageComments, props, [AppContainer, PageContainer, CommentContainer]);
+};
+
+PageComments.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+};
+
+export default withTranslation()(PageCommentsWrapper);

+ 50 - 58
src/client/js/components/PageEditor.js → src/client/js/components/PageEditor.jsx

@@ -4,37 +4,34 @@ import PropTypes from 'prop-types';
 import { throttle, debounce } from 'throttle-debounce';
 
 import * as toastr from 'toastr';
-import GrowiRenderer from '../util/GrowiRenderer';
 
-import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import EditorContainer from '../services/EditorContainer';
 
 
-export default class PageEditor extends React.Component {
+class PageEditor extends React.Component {
 
   constructor(props) {
     super(props);
 
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
-      pageId: this.props.pageId,
-      revisionId: this.props.revisionId,
-      markdown: this.props.markdown,
+      markdown: this.props.pageContainer.state.markdown,
       isUploadable,
       isUploadableFile,
       isMathJaxEnabled,
-      editorOptions: this.props.editorOptions,
-      previewOptions: this.props.previewOptions,
     };
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, { mode: 'editor' });
-
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
@@ -48,6 +45,9 @@ export default class PageEditor extends React.Component {
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
 
+    // get renderer
+    this.growiRenderer = this.props.appContainer.getRenderer('editor');
+
     // for scrolling
     this.lastScrolledDateWithCursor = null;
     this.isOriginOfScrollSyncEditor = false;
@@ -59,21 +59,24 @@ export default class PageEditor extends React.Component {
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
+
   }
 
   componentWillMount() {
+    this.props.appContainer.registerComponentInstance(this);
+
     // initial rendering
     this.renderPreview(this.state.markdown);
 
-    this.props.crowi.window.addEventListener('beforeunload', this.showUnsavedWarning);
+    window.addEventListener('beforeunload', this.showUnsavedWarning);
   }
 
   componentWillUnmount() {
-    this.props.crowi.window.removeEventListener('beforeunload', this.showUnsavedWarning);
+    window.removeEventListener('beforeunload', this.showUnsavedWarning);
   }
 
   showUnsavedWarning(e) {
-    if (!this.props.crowi.getIsDocSaved()) {
+    if (!this.props.appContainer.getIsDocSaved()) {
       // display browser default message
       e.returnValue = '';
       return '';
@@ -84,11 +87,8 @@ export default class PageEditor extends React.Component {
     return this.state.markdown;
   }
 
-  setMarkdown(markdown, updateEditorValue = true) {
-    this.setState({ markdown });
-    if (updateEditorValue) {
-      this.editor.setValue(markdown);
-    }
+  updateEditorValue(markdown) {
+    this.editor.setValue(markdown);
   }
 
   focusToEditor() {
@@ -104,22 +104,6 @@ export default class PageEditor extends React.Component {
     scrollSyncHelper.scrollPreview(this.previewElement, line);
   }
 
-  /**
-   * set options (used from the outside)
-   * @param {object} editorOptions
-   */
-  setEditorOptions(editorOptions) {
-    this.setState({ editorOptions });
-  }
-
-  /**
-   * set options (used from the outside)
-   * @param {object} previewOptions
-   */
-  setPreviewOptions(previewOptions) {
-    this.setState({ previewOptions });
-  }
-
   /**
    * the change event handler for `markdown` state
    * @param {string} value
@@ -127,12 +111,12 @@ export default class PageEditor extends React.Component {
   onMarkdownChanged(value) {
     this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
-    this.props.crowi.setIsDocSaved(false);
+    this.props.appContainer.setIsDocSaved(false);
   }
 
   onSave() {
     this.props.onSaveWithShortcut(this.state.markdown);
-    this.props.crowi.setIsDocSaved(true);
+    this.props.appContainer.setIsDocSaved(true);
   }
 
   /**
@@ -141,18 +125,22 @@ export default class PageEditor extends React.Component {
    */
   async onUpload(file) {
     try {
-      let res = await this.props.crowi.apiGet('/attachments.limit', { _csrf: this.props.crowi.csrfToken, fileSize: file.size });
+      let res = await this.props.appContainer.apiGet('/attachments.limit', {
+        _csrf: this.props.appContainer.csrfToken,
+        fileSize: file.size,
+      });
+
       if (!res.isUploadable) {
         throw new Error(res.errorMessage);
       }
 
       const formData = new FormData();
-      formData.append('_csrf', this.props.crowi.csrfToken);
+      formData.append('_csrf', this.props.appContainer.csrfToken);
       formData.append('file', file);
-      formData.append('path', this.props.pagePath);
+      formData.append('path', this.props.pageContainer.state.path);
       formData.append('page_id', this.state.pageId || 0);
 
-      res = await this.props.crowi.apiPost('/attachments.add', formData);
+      res = await this.props.appContainer.apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
@@ -275,14 +263,15 @@ export default class PageEditor extends React.Component {
   }
 
   saveDraft() {
+    const { pageContainer, editorContainer } = this.props;
     // only when the first time to edit
-    if (!this.state.revisionId) {
-      this.props.crowi.saveDraft(this.props.pagePath, this.state.markdown);
+    if (!pageContainer.state.revisionId) {
+      editorContainer.saveDraft(pageContainer.state.path, this.state.markdown);
     }
   }
 
   clearDraft() {
-    this.props.crowi.clearDraft(this.props.pagePath);
+    this.props.editorContainer.clearDraft(this.props.pageContainer.state.path);
   }
 
   renderPreview(value) {
@@ -295,7 +284,7 @@ export default class PageEditor extends React.Component {
     };
 
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
@@ -332,9 +321,9 @@ export default class PageEditor extends React.Component {
   }
 
   render() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const noCdn = !!config.env.NO_CDN;
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
+    const emojiStrategy = this.props.appContainer.getEmojiStrategy();
 
     return (
       <div className="row">
@@ -342,9 +331,8 @@ export default class PageEditor extends React.Component {
           <Editor
             ref={(c) => { this.editor = c }}
             value={this.state.markdown}
-            editorOptions={this.state.editorOptions}
             noCdn={noCdn}
-            isMobile={this.props.crowi.isMobile}
+            isMobile={this.props.appContainer.isMobile}
             isUploadable={this.state.isUploadable}
             isUploadableFile={this.state.isUploadableFile}
             emojiStrategy={emojiStrategy}
@@ -362,7 +350,6 @@ export default class PageEditor extends React.Component {
             inputRef={(el) => { return this.previewElement = el }}
             isMathJaxEnabled={this.state.isMathJaxEnabled}
             renderMathJaxOnInit={false}
-            previewOptions={this.state.previewOptions}
             onScroll={this.onPreviewScroll}
           />
         </div>
@@ -372,14 +359,19 @@ export default class PageEditor extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorWrapper = (props) => {
+  return createSubscribedElement(PageEditor, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 PageEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   onSaveWithShortcut: PropTypes.func.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.instanceOf(EditorOptions),
-  previewOptions: PropTypes.instanceOf(PreviewOptions),
 };
+
+export default PageEditorWrapper;

+ 0 - 1
src/client/js/components/PageEditor/AbstractEditor.js → src/client/js/components/PageEditor/AbstractEditor.jsx

@@ -124,7 +124,6 @@ export default class AbstractEditor extends React.Component {
 AbstractEditor.propTypes = {
   value: PropTypes.string,
   isGfmMode: PropTypes.bool,
-  editorOptions: PropTypes.object,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
   onScrollCursorIntoView: PropTypes.func,

+ 0 - 0
src/client/js/components/PageEditor/Cheatsheet.js → src/client/js/components/PageEditor/Cheatsheet.jsx


+ 56 - 35
src/client/js/components/PageEditor/CodeMirrorEditor.js → src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -119,6 +119,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
   componentDidMount() {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
+
+    // load theme
+    const theme = this.props.editorOptions.theme;
+    this.loadTheme(theme);
+
+    // set keymap
+    const keymapMode = this.props.editorOptions.keymapMode;
+    this.setKeymapMode(keymapMode);
   }
 
   componentWillReceiveProps(nextProps) {
@@ -375,7 +383,24 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.loadKeymapMode(keymapMode)
       .then(() => {
-        this.getCodeMirror().setOption('keyMap', keymapMode);
+        let errorCount = 0;
+        const timer = setInterval(() => {
+          if (errorCount > 10) { // cancel over 3000ms
+            this.logger.error(`Timeout to load keyMap '${keymapMode}'`);
+            clearInterval(timer);
+          }
+
+          try {
+            this.getCodeMirror().setOption('keyMap', keymapMode);
+            clearInterval(timer);
+          }
+          catch (e) {
+            this.logger.info(`keyMap '${keymapMode}' has not been initialized. retry..`);
+
+            // continue if error occured
+            errorCount++;
+          }
+        }, 300);
       });
   }
 
@@ -717,12 +742,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   render() {
     const mode = this.state.isGfmMode ? 'gfm' : undefined;
-    const defaultEditorOptions = {
-      theme: 'elegant',
-      lineNumbers: true,
-    };
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
-    const editorOptions = Object.assign(defaultEditorOptions, this.props.editorOptions || {});
 
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
 
@@ -740,35 +760,35 @@ export default class CodeMirrorEditor extends AbstractEditor {
         }}
           value={this.state.value}
           options={{
-          mode,
-          theme: editorOptions.theme,
-          styleActiveLine: editorOptions.styleActiveLine,
-          lineNumbers: this.props.lineNumbers,
-          tabSize: 4,
-          indentUnit: 4,
-          lineWrapping: true,
-          autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
-          autoCloseTags: true,
-          placeholder,
-          matchBrackets: true,
-          matchTags: { bothTags: true },
-          // folding
-          foldGutter: this.props.lineNumbers,
-          gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
-          // match-highlighter, matchesonscrollbar, annotatescrollbar options
-          highlightSelectionMatches: { annotateScrollbar: true },
-          // markdown mode options
-          highlightFormatting: true,
-          // continuelist, indentlist
-          extraKeys: {
-            Enter: this.handleEnterKey,
-            'Ctrl-Enter': this.handleCtrlEnterKey,
-            'Cmd-Enter': this.handleCtrlEnterKey,
-            Tab: 'indentMore',
-            'Shift-Tab': 'indentLess',
-            'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
-          },
-        }}
+            mode,
+            theme: this.props.editorOptions.theme,
+            styleActiveLine: this.props.editorOptions.styleActiveLine,
+            lineNumbers: this.props.lineNumbers,
+            tabSize: 4,
+            indentUnit: 4,
+            lineWrapping: true,
+            autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
+            autoCloseTags: true,
+            placeholder,
+            matchBrackets: true,
+            matchTags: { bothTags: true },
+            // folding
+            foldGutter: this.props.lineNumbers,
+            gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
+            // match-highlighter, matchesonscrollbar, annotatescrollbar options
+            highlightSelectionMatches: { annotateScrollbar: true },
+            // markdown mode options
+            highlightFormatting: true,
+            // continuelist, indentlist
+            extraKeys: {
+              Enter: this.handleEnterKey,
+              'Ctrl-Enter': this.handleCtrlEnterKey,
+              'Cmd-Enter': this.handleCtrlEnterKey,
+              Tab: 'indentMore',
+              'Shift-Tab': 'indentLess',
+              'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
+            },
+          }}
           onCursor={this.cursorHandler}
           onScroll={(editor, data) => {
           if (this.props.onScroll != null) {
@@ -804,6 +824,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 }
 
 CodeMirrorEditor.propTypes = Object.assign({
+  editorOptions: PropTypes.object.isRequired,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
 }, AbstractEditor.propTypes);

+ 17 - 10
src/client/js/components/PageEditor/Editor.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { Subscribe } from 'unstated';
+
 import Dropzone from 'react-dropzone';
 import AbstractEditor from './AbstractEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
@@ -8,6 +10,7 @@ import TextAreaEditor from './TextAreaEditor';
 
 
 import pasteHelper from './PasteHelper';
+import EditorContainer from '../../services/EditorContainer';
 
 export default class Editor extends AbstractEditor {
 
@@ -271,14 +274,19 @@ export default class Editor extends AbstractEditor {
 
                 {/* for PC */}
                 { !isMobile && (
-                  <CodeMirrorEditor
-                    ref={(c) => { this.cmEditor = c }}
-                    onPasteFiles={this.pasteFilesHandler}
-                    onDragEnter={this.dragEnterHandler}
-                    {...this.props}
-                  />
-                  )
-                }
+                  <Subscribe to={[EditorContainer]}>
+                    { editorContainer => (
+                      // eslint-disable-next-line arrow-body-style
+                      <CodeMirrorEditor
+                        ref={(c) => { this.cmEditor = c }}
+                        editorOptions={editorContainer.state.editorOptions}
+                        onPasteFiles={this.pasteFilesHandler}
+                        onDragEnter={this.dragEnterHandler}
+                        {...this.props}
+                      />
+                    )}
+                  </Subscribe>
+                )}
 
                 {/* for mobile */}
                 { isMobile && (
@@ -288,8 +296,7 @@ export default class Editor extends AbstractEditor {
                     onDragEnter={this.dragEnterHandler}
                     {...this.props}
                   />
-                  )
-                }
+                )}
 
                 <input {...getInputProps()} />
               </div>

+ 0 - 0
src/client/js/components/PageEditor/MarkdownTableUtil.js → src/client/js/components/PageEditor/MarkdownTableUtil.jsx


+ 56 - 54
src/client/js/components/PageEditor/OptionsSelector.js → src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
@@ -9,27 +10,19 @@ import ControlLabel from 'react-bootstrap/es/ControlLabel';
 import Dropdown from 'react-bootstrap/es/Dropdown';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
-export class EditorOptions {
-
-  constructor(props) {
-    this.theme = 'elegant';
-    this.keymapMode = 'default';
-    this.styleActiveLine = false;
-
-    Object.assign(this, props);
-  }
-
-}
+import { createSubscribedElement } from '../UnstatedUtils';
+import EditorContainer from '../../services/EditorContainer';
 
-export class PreviewOptions {
 
-  constructor(props) {
-    this.renderMathJaxInRealtime = false;
-
-    Object.assign(this, props);
-  }
+export const defaultEditorOptions = {
+  theme: 'elegant',
+  keymapMode: 'default',
+  styleActiveLine: false,
+};
 
-}
+export const defaultPreviewOptions = {
+  renderMathJaxInRealtime: false,
+};
 
 class OptionsSelector extends React.Component {
 
@@ -40,8 +33,6 @@ class OptionsSelector extends React.Component {
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
-      editorOptions: this.props.editorOptions || new EditorOptions(),
-      previewOptions: this.props.previewOptions || new PreviewOptions(),
       isCddMenuOpened: false,
       isMathJaxEnabled,
     };
@@ -68,50 +59,60 @@ class OptionsSelector extends React.Component {
   }
 
   init() {
-    this.themeSelectorInputEl.value = this.state.editorOptions.theme;
-    this.keymapModeSelectorInputEl.value = this.state.editorOptions.keymapMode;
+    const { editorContainer } = this.props;
+
+    this.themeSelectorInputEl.value = editorContainer.state.editorOptions.theme;
+    this.keymapModeSelectorInputEl.value = editorContainer.state.editorOptions.keymapMode;
   }
 
   onChangeTheme() {
+    const { editorContainer } = this.props;
+
     const newValue = this.themeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, { theme: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { theme: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   onChangeKeymapMode() {
+    const { editorContainer } = this.props;
+
     const newValue = this.keymapModeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, { keymapMode: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { keymapMode: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   onClickStyleActiveLine(event) {
+    const { editorContainer } = this.props;
+
     // keep dropdown opened
     this._cddForceOpen = true;
 
-    const newValue = !this.state.editorOptions.styleActiveLine;
-    const newOpts = Object.assign(this.state.editorOptions, { styleActiveLine: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newValue = !editorContainer.state.editorOptions.styleActiveLine;
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { styleActiveLine: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   onClickRenderMathJaxInRealtime(event) {
+    const { editorContainer } = this.props;
+
     // keep dropdown opened
     this._cddForceOpen = true;
 
-    const newValue = !this.state.previewOptions.renderMathJaxInRealtime;
-    const newOpts = Object.assign(this.state.previewOptions, { renderMathJaxInRealtime: newValue });
-    this.setState({ previewOptions: newOpts });
+    const newValue = !editorContainer.state.previewOptions.renderMathJaxInRealtime;
+    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderMathJaxInRealtime: newValue });
+    editorContainer.setState({ previewOptions: newOpts });
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
 
   /*
@@ -127,13 +128,6 @@ class OptionsSelector extends React.Component {
     }
   }
 
-  /**
-   * dispatch onChange event
-   */
-  dispatchOnChange() {
-    this.props.onChange(this.state.editorOptions, this.state.previewOptions);
-  }
-
   renderThemeSelector() {
     const optionElems = this.availableThemes.map((theme) => {
       return <option key={theme} value={theme}>{theme}</option>;
@@ -225,8 +219,8 @@ class OptionsSelector extends React.Component {
   }
 
   renderActiveLineMenuItem() {
-    const { t } = this.props;
-    const isActive = this.state.editorOptions.styleActiveLine;
+    const { t, editorContainer } = this.props;
+    const isActive = editorContainer.state.editorOptions.styleActiveLine;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -248,8 +242,10 @@ class OptionsSelector extends React.Component {
       return;
     }
 
+    const { editorContainer } = this.props;
+
     const isEnabled = this.state.isMathJaxEnabled;
-    const isActive = isEnabled && this.state.previewOptions.renderMathJaxInRealtime;
+    const isActive = isEnabled && editorContainer.state.previewOptions.renderMathJaxInRealtime;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -278,13 +274,19 @@ class OptionsSelector extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const OptionsSelectorWrapper = (props) => {
+  return createSubscribedElement(OptionsSelector, props, [EditorContainer]);
+};
 
 OptionsSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   crowi: PropTypes.object.isRequired,
-  editorOptions: PropTypes.instanceOf(EditorOptions).isRequired,
-  previewOptions: PropTypes.instanceOf(PreviewOptions).isRequired,
-  onChange: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(OptionsSelector);
+export default withTranslation()(OptionsSelectorWrapper);

+ 0 - 47
src/client/js/components/PageEditor/Preview.js

@@ -1,47 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import RevisionBody from '../Page/RevisionBody';
-
-import { PreviewOptions } from './OptionsSelector';
-
-/**
- * Wrapper component for Page/RevisionBody
- */
-export default class Preview extends React.Component {
-
-  render() {
-    const renderMathJaxInRealtime = this.props.previewOptions.renderMathJaxInRealtime;
-
-    return (
-      <div
-        className="page-editor-preview-body"
-        ref={(elm) => {
-            this.previewElement = elm;
-            this.props.inputRef(elm);
-          }}
-        onScroll={(event) => {
-            if (this.props.onScroll != null) {
-              this.props.onScroll(event.target.scrollTop);
-            }
-          }}
-      >
-
-        <RevisionBody
-          {...this.props}
-          renderMathJaxInRealtime={renderMathJaxInRealtime}
-        />
-      </div>
-    );
-  }
-
-}
-
-Preview.propTypes = {
-  html: PropTypes.string,
-  inputRef: PropTypes.func.isRequired, // for getting div element
-  isMathJaxEnabled: PropTypes.bool,
-  renderMathJaxOnInit: PropTypes.bool,
-  previewOptions: PropTypes.instanceOf(PreviewOptions),
-  onScroll: PropTypes.func,
-};

+ 50 - 0
src/client/js/components/PageEditor/Preview.jsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Subscribe } from 'unstated';
+
+import RevisionBody from '../Page/RevisionBody';
+
+import EditorContainer from '../../services/EditorContainer';
+
+/**
+ * Wrapper component for Page/RevisionBody
+ */
+export default class Preview extends React.PureComponent {
+
+  render() {
+    return (
+      <Subscribe to={[EditorContainer]}>
+        { editorContainer => (
+          // eslint-disable-next-line arrow-body-style
+          <div
+            className="page-editor-preview-body"
+            ref={(elm) => {
+                this.previewElement = elm;
+                this.props.inputRef(elm);
+              }}
+            onScroll={(event) => {
+                if (this.props.onScroll != null) {
+                  this.props.onScroll(event.target.scrollTop);
+                }
+              }}
+          >
+            <RevisionBody
+              {...this.props}
+              renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
+            />
+          </div>
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
+Preview.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func.isRequired, // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  onScroll: PropTypes.func,
+};

+ 0 - 0
src/client/js/components/PageEditor/SimpleCheatsheet.js → src/client/js/components/PageEditor/SimpleCheatsheet.jsx


+ 0 - 0
src/client/js/components/PageEditor/TextAreaEditor.js → src/client/js/components/PageEditor/TextAreaEditor.jsx


+ 39 - 66
src/client/js/components/PageEditorByHackmd.jsx

@@ -6,22 +6,21 @@ import MenuItem from 'react-bootstrap/es/MenuItem';
 
 import * as toastr from 'toastr';
 
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
-export default class PageEditorByHackmd extends React.PureComponent {
+class PageEditorByHackmd extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
-      markdown: this.props.markdown,
+      markdown: this.props.pageContainer.state.markdown,
       isInitialized: false,
       isInitializing: false,
-      initialRevisionId: this.props.revisionId,
-      revisionId: this.props.revisionId,
-      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
-      pageIdOnHackmd: this.props.pageIdOnHackmd,
-      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
@@ -33,6 +32,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
   }
 
   componentWillMount() {
+    this.props.appContainer.registerComponentInstance(this);
   }
 
   /**
@@ -51,13 +51,6 @@ export default class PageEditorByHackmd extends React.PureComponent {
       });
   }
 
-  setMarkdown(markdown, updateEditorValue = true) {
-    this.setState({ markdown });
-    if (this.state.isInitialized && updateEditorValue) {
-      this.hackmdEditor.setValue(markdown);
-    }
-  }
-
   /**
    * reset initialized status
    */
@@ -65,40 +58,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
     this.setState({ isInitialized: false });
   }
 
-  /**
-   * clear revision status (invoked when page is updated by myself)
-   */
-  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
-    this.setState({
-      initialRevisionId: updatedRevisionId,
-      revisionId: updatedRevisionId,
-      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
-    });
-  }
-
-  /**
-   * update revisionId of state
-   * @param {string} revisionId
-   * @param {string} revisionIdHackmdSynced
-   */
-  setRevisionId(revisionId, revisionIdHackmdSynced) {
-    this.setState({ revisionId, revisionIdHackmdSynced });
-  }
-
-  getRevisionIdHackmdSynced() {
-    return this.state.revisionIdHackmdSynced;
-  }
-
-  /**
-   * update hasDraftOnHackmd of state
-   * @param {bool} hasDraftOnHackmd
-   */
-  setHasDraftOnHackmd(hasDraftOnHackmd) {
-    this.setState({ hasDraftOnHackmd });
-  }
-
   getHackmdUri() {
-    const envVars = this.props.crowi.config.env;
+    const envVars = this.props.appContainer.getConfig().env;
     return envVars.HACKMD_URI;
   }
 
@@ -106,6 +67,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * Start integration with HackMD
    */
   startToEdit() {
+    const { pageContainer } = this.props;
     const hackmdUri = this.getHackmdUri();
 
     if (hackmdUri == null) {
@@ -119,9 +81,9 @@ export default class PageEditorByHackmd extends React.PureComponent {
     });
 
     const params = {
-      pageId: this.props.pageId,
+      pageId: pageContainer.state.pageId,
     };
-    this.props.crowi.apiPost('/hackmd.integrate', params)
+    this.props.appContainer.apiPost('/hackmd.integrate', params)
       .then((res) => {
         if (!res.ok) {
           throw new Error(res.error);
@@ -129,6 +91,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
         this.setState({
           isInitialized: true,
+        });
+        pageContainer.setState({
           pageIdOnHackmd: res.pageIdOnHackmd,
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
         });
@@ -150,7 +114,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * Reset draft
    */
   discardChanges() {
-    this.setState({ hasDraftOnHackmd: false });
+    this.props.pageContainer.setState({ hasDraftOnHackmd: false });
   }
 
   /**
@@ -158,6 +122,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    */
   hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
 
     if (hackmdUri == null) {
       // do nothing
@@ -165,14 +130,14 @@ export default class PageEditorByHackmd extends React.PureComponent {
     }
 
     // do nothing if contents are same
-    if (this.props.markdown === body) {
+    if (pageContainer.state.markdown === body) {
       return;
     }
 
     const params = {
-      pageId: this.props.pageId,
+      pageId: pageContainer.state.pageId,
     };
-    this.props.crowi.apiPost('/hackmd.saveOnHackmd', params)
+    this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params)
       .then((res) => {
         // do nothing
       })
@@ -194,16 +159,20 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
   render() {
     const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
+    const {
+      pageIdOnHackmd, revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd,
+    } = pageContainer.state;
 
-    const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
-    const isResume = isPageExistsOnHackmd && this.state.hasDraftOnHackmd;
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
     if (this.state.isInitialized) {
       return (
         <HackmdEditor
           ref={(c) => { this.hackmdEditor = c }}
           hackmdUri={hackmdUri}
-          pageIdOnHackmd={this.state.pageIdOnHackmd}
+          pageIdOnHackmd={pageIdOnHackmd}
           initializationMarkdown={isResume ? null : this.state.markdown}
           onChange={this.hackmdEditorChangeHandler}
           onSaveWithShortcut={(document) => {
@@ -214,8 +183,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
       );
     }
 
-    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
-    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+    const isRevisionOutdated = revisionId !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
     let content;
     /*
@@ -232,7 +201,6 @@ export default class PageEditorByHackmd extends React.PureComponent {
      * Resume to edit or discard changes
      */
     else if (isResume) {
-      const revisionIdHackmdSynced = this.state.revisionIdHackmdSynced;
       const title = (
         <React.Fragment>
           <span className="btn-label"><i className="icon-control-end"></i></span>
@@ -320,13 +288,18 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorByHackmdWrapper = (props) => {
+  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer]);
+};
+
 PageEditorByHackmd.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  markdown: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   onSaveWithShortcut: PropTypes.func.isRequired,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pageIdOnHackmd: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
-  hasDraftOnHackmd: PropTypes.bool,
 };
+
+export default PageEditorByHackmdWrapper;

+ 0 - 167
src/client/js/components/PageList/Draft.jsx

@@ -1,167 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import GrowiRenderer from '../../util/GrowiRenderer';
-
-import RevisionBody from '../Page/RevisionBody';
-
-class Draft extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      html: '',
-      isOpen: false,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'draft' });
-
-    this.renderHtml = this.renderHtml.bind(this);
-    this.toggleContent = this.toggleContent.bind(this);
-    this.copyMarkdownToClipboard = this.copyMarkdownToClipboard.bind(this);
-    this.renderAccordionTitle = this.renderAccordionTitle.bind(this);
-  }
-
-  copyMarkdownToClipboard() {
-    navigator.clipboard.writeText(this.props.markdown);
-  }
-
-  async toggleContent(e) {
-    const target = e.currentTarget.getAttribute('data-target');
-
-    if (!this.state.html) {
-      await this.renderHtml();
-    }
-
-    if (this.state.isOpen) {
-      $(target).collapse('hide');
-      this.setState({ isOpen: false });
-    }
-    else {
-      $(target).collapse('show');
-      this.setState({ isOpen: true });
-    }
-  }
-
-  async renderHtml() {
-    const context = {
-      markdown: this.props.markdown,
-    };
-
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
-    await interceptorManager.process('prePreProcess', context)
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      });
-  }
-
-  renderAccordionTitle(isExist) {
-    const iconClass = this.state.isOpen ? 'caret-opened' : '';
-
-    if (isExist) {
-      return (
-        <Fragment>
-          <i className={`caret ${iconClass}`}></i>
-          <span className="mx-2">{this.props.path}</span>
-          <span>({this.props.t('page exists')})</span>
-        </Fragment>
-      );
-    }
-
-    return (
-      <Fragment>
-        <i className={`caret ${iconClass}`}></i>
-        <a className="mx-2" href={`${this.props.path}#edit`} target="_blank" rel="noopener noreferrer">{this.props.path}</a>
-        <span className="label-draft label label-default">draft</span>
-      </Fragment>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    const id = this.props.path.replace('/', '-');
-
-    return (
-      <div className="draft-list-item">
-        <div className="panel">
-          <div className="panel-heading d-flex justify-content-between">
-            <div className="panel-title" onClick={this.toggleContent} data-target={`#${id}`}>
-              {this.renderAccordionTitle(this.props.isExist)}
-            </div>
-            <div className="icon-container">
-              {this.props.isExist
-                ? null
-                : (
-                  <a
-                    href={`${this.props.path}#edit`}
-                    target="_blank"
-                    rel="noopener noreferrer"
-                    className="draft-edit"
-                    data-toggle="tooltip"
-                    data-placement="bottom"
-                    title={this.props.t('Edit')}
-                  >
-                    <i className="icon-note" />
-                  </a>
-                )
-              }
-              <a
-                className="draft-copy"
-                data-toggle="tooltip"
-                data-placement="bottom"
-                title={this.props.t('Copy')}
-                onClick={this.copyMarkdownToClipboard}
-              >
-                <i className="icon-doc" />
-              </a>
-              <a
-                className="text-danger draft-delete"
-                data-toggle="tooltip"
-                data-placement="top"
-                title={t('Delete')}
-                onClick={() => { return this.props.clearDraft(this.props.path) }}
-              >
-                <i className="icon-trash" />
-              </a>
-            </div>
-          </div>
-          <div className="panel-body collapse" id={id} aria-labelledby={id} data-parent="#draft-list">
-            <div className="revision-body wiki">
-              <RevisionBody html={this.state.html} />
-            </div>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-Draft.propTypes = {
-  t: PropTypes.func.isRequired,
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  path: PropTypes.string.isRequired,
-  markdown: PropTypes.string.isRequired,
-  isExist: PropTypes.bool.isRequired,
-  clearDraft: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(Draft);

+ 1 - 3
src/client/js/components/PageList/PagePath.js

@@ -30,7 +30,7 @@ export default class PagePath extends React.Component {
   render() {
     const page = this.props.page;
     const isShortPathOnly = this.props.isShortPathOnly;
-    const pagePath = decodeURIComponent(page.path.replace(this.props.excludePathString.replace(/^\//, ''), ''));
+    const pagePath = decodeURIComponent(page.path);
     const shortPath = this.getShortPath(pagePath);
 
     const shortPathEscaped = escapeStringRegexp(shortPath);
@@ -51,11 +51,9 @@ export default class PagePath extends React.Component {
 PagePath.propTypes = {
   page: PropTypes.object.isRequired,
   isShortPathOnly: PropTypes.bool,
-  excludePathString: PropTypes.string,
   additionalClassNames: PropTypes.array,
 };
 
 PagePath.defaultProps = {
   additionalClassNames: [],
-  excludePathString: '',
 };

+ 30 - 43
src/client/js/components/PageStatusAlert.jsx

@@ -1,7 +1,13 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
+
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -17,12 +23,6 @@ class PageStatusAlert extends React.Component {
     super(props);
 
     this.state = {
-      initialRevisionId: this.props.revisionId,
-      revisionId: this.props.revisionId,
-      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
-      lastUpdateUsername: undefined,
-      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
-      isDraftUpdatingInRealtime: false,
     };
 
     this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
@@ -30,32 +30,8 @@ class PageStatusAlert extends React.Component {
     this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
   }
 
-  /**
-   * clear status (invoked when page is updated by myself)
-   */
-  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
-    this.setState({
-      initialRevisionId: updatedRevisionId,
-      revisionId: updatedRevisionId,
-      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
-      hasDraftOnHackmd: false,
-      isDraftUpdatingInRealtime: false,
-    });
-  }
-
-  setRevisionId(revisionId, revisionIdHackmdSynced) {
-    this.setState({ revisionId, revisionIdHackmdSynced });
-  }
-
-  setLastUpdateUsername(lastUpdateUsername) {
-    this.setState({ lastUpdateUsername });
-  }
-
-  setHasDraftOnHackmd(hasDraftOnHackmd) {
-    this.setState({
-      hasDraftOnHackmd,
-      isDraftUpdatingInRealtime: true,
-    });
+  componentWillMount() {
+    this.props.appContainer.registerComponentInstance(this);
   }
 
   refreshPage() {
@@ -100,7 +76,7 @@ class PageStatusAlert extends React.Component {
     return (
       <div className="alert-revision-outdated myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
         <i className="icon-fw icon-bulb"></i>
-        {this.state.lastUpdateUsername} {label1}
+        {this.props.pageContainer.state.lastUpdateUsername} {label1}
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
@@ -114,16 +90,23 @@ class PageStatusAlert extends React.Component {
   render() {
     let content = <React.Fragment></React.Fragment>;
 
-    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
-    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+    const {
+      revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
+    } = this.props.pageContainer.state;
+
+    const isRevisionOutdated = revisionId !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
+    // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
       content = this.renderUpdatedAlert();
     }
-    else if (this.state.isDraftUpdatingInRealtime) {
+    // when someone editing with HackMD
+    else if (isHackmdDraftUpdatingInRealtime) {
       content = this.renderSomeoneEditingAlert();
     }
-    else if (this.state.hasDraftOnHackmd) {
+    // when the draft of HackMD is newest
+    else if (hasDraftOnHackmd) {
       content = this.renderDraftExistsAlert();
     }
 
@@ -132,14 +115,18 @@ class PageStatusAlert extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageStatusAlertWrapper = (props) => {
+  return createSubscribedElement(PageStatusAlert, props, [AppContainer, PageContainer]);
+};
+
 PageStatusAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  hasDraftOnHackmd: PropTypes.bool.isRequired,
-  revisionId: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
-};
 
-PageStatusAlert.defaultProps = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withTranslation(null, { withRef: true })(PageStatusAlert);
+export default withTranslation()(PageStatusAlertWrapper);

+ 0 - 28
src/client/js/components/ReactUtils.js

@@ -1,28 +0,0 @@
-import React from 'react';
-
-export default class ReactUtils {
-
-  /**
-   * show '\n' as '<br>'
-   *
-   * @see http://qiita.com/kouheiszk/items/e7c74ab5eab901f89a7f
-   *
-   * @static
-   * @param {any} text
-   * @returns
-   *
-   * @memberOf ReactUtils
-   */
-  static nl2br(text) {
-    const regex = /(\n)/g;
-    return text.split(regex).map((line) => {
-      if (line.match(regex)) {
-        return React.createElement('br', { key: Math.random().toString(10).substr(2, 10) });
-      }
-
-      return line;
-
-    });
-  }
-
-}

+ 22 - 11
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -1,10 +1,15 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
+
 import Pagination from 'react-bootstrap/lib/Pagination';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
 import Page from '../PageList/Page';
 
-export default class RecentCreated extends React.Component {
+class RecentCreated extends React.Component {
 
   constructor(props) {
     super(props);
@@ -23,13 +28,15 @@ export default class RecentCreated extends React.Component {
   }
 
   getRecentCreatedList(selectPageNumber) {
-    const pageId = this.props.pageId;
-    const userId = this.props.crowi.me;
-    const limit = this.props.limit;
+    const { appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    const userId = appContainer.me;
+    const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
 
     // pagesList get and pagination calculate
-    this.props.crowi.apiGet('/pages.recentCreated', {
+    this.props.appContainer.apiGet('/pages.recentCreated', {
       page_id: pageId, user: userId, limit, offset,
     })
       .then((res) => {
@@ -183,12 +190,16 @@ export default class RecentCreated extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const RecentCreatedWrapper = (props) => {
+  return createSubscribedElement(RecentCreated, props, [AppContainer, PageContainer]);
+};
 
 RecentCreated.propTypes = {
-  pageId: PropTypes.string.isRequired,
-  crowi: PropTypes.object.isRequired,
-  limit: PropTypes.number,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-RecentCreated.defaultProps = {
-};
+export default RecentCreatedWrapper;

+ 43 - 44
src/client/js/components/SavePageControls.jsx

@@ -1,53 +1,52 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
 import ButtonToolbar from 'react-bootstrap/es/ButtonToolbar';
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
+import PageContainer from '../services/PageContainer';
+import AppContainer from '../services/AppContainer';
+import EditorContainer from '../services/EditorContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import SlackNotification from './SlackNotification';
 import GrantSelector from './SavePageControls/GrantSelector';
 
-class SavePageControls extends React.PureComponent {
+
+class SavePageControls extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-      pageId: this.props.pageId,
-    };
-
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     this.hasSlackConfig = config.hasSlackConfig;
     this.isAclEnabled = config.isAclEnabled;
 
-    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
+    this.slackEnabledFlagChangedHandler = this.slackEnabledFlagChangedHandler.bind(this);
+    this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
+    this.updateGrantHandler = this.updateGrantHandler.bind(this);
+
     this.submit = this.submit.bind(this);
     this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
   }
 
-  componentWillMount() {
+  slackEnabledFlagChangedHandler(isSlackEnabled) {
+    this.props.editorContainer.setState({ isSlackEnabled });
   }
 
-  getCurrentOptionsToSave() {
-    let currentOptions = this.grantSelector.getCurrentOptionsToSave();
-    if (this.hasSlackConfig) {
-      currentOptions = Object.assign(currentOptions, this.slackNotification.getCurrentOptionsToSave());
-    }
-    return currentOptions;
+  slackChannelsChangedHandler(slackChannels) {
+    this.props.editorContainer.setState({ slackChannels });
   }
 
-  /**
-   * update pageId of state
-   * @param {string} pageId
-   */
-  setPageId(pageId) {
-    this.setState({ pageId });
+  updateGrantHandler(data) {
+    this.props.editorContainer.setState(data);
   }
 
   submit() {
-    this.props.crowi.setIsDocSaved(true);
+    this.props.appContainer.setIsDocSaved(true);
     this.props.onSubmit();
   }
 
@@ -56,8 +55,8 @@ class SavePageControls extends React.PureComponent {
   }
 
   render() {
-    const { t } = this.props;
-    const labelSubmitButton = this.state.pageId == null ? t('Create') : t('Update');
+    const { t, editorContainer } = this.props;
+    const labelSubmitButton = this.props.pageContainer.state.pageId == null ? t('Create') : t('Update');
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
@@ -66,9 +65,10 @@ class SavePageControls extends React.PureComponent {
           && (
           <div className="mr-2">
             <SlackNotification
-              ref={(c) => { this.slackNotification = c }}
-              isSlackEnabled={false}
-              slackChannels={this.props.slackChannels}
+              isSlackEnabled={editorContainer.state.isSlackEnabled}
+              slackChannels={editorContainer.state.slackChannels}
+              onEnabledFlagChange={this.slackEnabledFlagChangedHandler}
+              onChannelChange={this.slackChannelsChangedHandler}
             />
           </div>
           )
@@ -78,15 +78,10 @@ class SavePageControls extends React.PureComponent {
           && (
           <div className="mr-2">
             <GrantSelector
-              crowi={this.props.crowi}
-              ref={(elem) => {
-                  if (this.grantSelector == null) {
-                    this.grantSelector = elem;
-                  }
-                }}
-              grant={this.props.grant}
-              grantGroupId={this.props.grantGroupId}
-              grantGroupName={this.props.grantGroupName}
+              grant={editorContainer.state.grant}
+              grantGroupId={editorContainer.state.grantGroupId}
+              grantGroupName={editorContainer.state.grantGroupName}
+              onUpdateGrant={this.updateGrantHandler}
             />
           </div>
           )
@@ -112,17 +107,21 @@ class SavePageControls extends React.PureComponent {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SavePageControlsWrapper = (props) => {
+  return createSubscribedElement(SavePageControls, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   onSubmit: PropTypes.func.isRequired,
-  pageId: PropTypes.string,
-  // for SlackNotification
-  slackChannels: PropTypes.string,
-  // for GrantSelector
-  grant: PropTypes.number,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
 };
 
-export default withTranslation(null, { withRef: true })(SavePageControls);
+export default withTranslation()(SavePageControlsWrapper);

+ 31 - 18
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
@@ -8,6 +9,10 @@ import ListGroup from 'react-bootstrap/es/ListGroup';
 import ListGroupItem from 'react-bootstrap/es/ListGroupItem';
 import Modal from 'react-bootstrap/es/Modal';
 
+import AppContainer from '../../services/AppContainer';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+
 const SPECIFIED_GROUP_VALUE = 'specifiedGroup';
 
 /**
@@ -42,11 +47,12 @@ class GrantSelector extends React.Component {
     ];
 
     this.state = {
-      grant: this.props.grant || 1, // default: 1
       userRelatedGroups: [],
       isSelectGroupModalShown: false,
+      grant: this.props.grant,
+      grantGroup: null,
     };
-    if (this.props.grantGroupId !== '') {
+    if (this.props.grantGroupId != null) {
       this.state.grantGroup = {
         _id: this.props.grantGroupId,
         name: this.props.grantGroupName,
@@ -56,7 +62,6 @@ class GrantSelector extends React.Component {
     // retrieve xss library from window
     this.xss = window.xss;
 
-    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
     this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
     this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
@@ -85,16 +90,6 @@ class GrantSelector extends React.Component {
 
   }
 
-  getCurrentOptionsToSave() {
-    const options = {
-      grant: this.state.grant,
-    };
-    if (this.state.grantGroup != null) {
-      options.grantUserGroupId = this.state.grantGroup._id;
-    }
-    return options;
-  }
-
   showSelectGroupModal() {
     this.retrieveUserGroupRelations();
     this.setState({ isSelectGroupModalShown: true });
@@ -113,7 +108,7 @@ class GrantSelector extends React.Component {
    * Retrieve user-group-relations data from backend
    */
   retrieveUserGroupRelations() {
-    this.props.crowi.apiGet('/me/user-group-relations')
+    this.props.appContainer.apiGet('/me/user-group-relations')
       .then((res) => {
         return res.userGroupRelations;
       })
@@ -142,11 +137,19 @@ class GrantSelector extends React.Component {
     }
 
     this.setState({ grant, grantGroup: null });
+
+    if (this.props.onUpdateGrant != null) {
+      this.props.onUpdateGrant({ grant, grantGroupId: null, grantGroupName: null });
+    }
   }
 
   groupListItemClickHandler(grantGroup) {
     this.setState({ grant: 5, grantGroup });
 
+    if (this.props.onUpdateGrant != null) {
+      this.props.onUpdateGrant({ grant: 5, grantGroupId: grantGroup._id, grantGroupName: grantGroup.name });
+    }
+
     // hide modal
     this.hideSelectGroupModal();
   }
@@ -239,7 +242,7 @@ class GrantSelector extends React.Component {
       ? (
         <div>
           <h4>There is no group to which you belong.</h4>
-          { this.props.crowi.isAdmin
+          { this.props.appContainer.isAdmin
             && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
           }
         </div>
@@ -280,12 +283,22 @@ class GrantSelector extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const GrantSelectorWrapper = (props) => {
+  return createSubscribedElement(GrantSelector, props, [AppContainer]);
+};
+
 GrantSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  grant: PropTypes.number,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
+
+  onUpdateGrant: PropTypes.func,
 };
 
-export default withTranslation(null, { withRef: true })(GrantSelector);
+export default withTranslation()(GrantSelectorWrapper);

+ 15 - 3
src/client/js/components/SearchForm.js

@@ -1,9 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
 import SearchTypeahead from './SearchTypeahead';
 
 // SearchTypeahead wrapper
-export default class SearchForm extends React.Component {
+class SearchForm extends React.Component {
 
   constructor(props) {
     super(props);
@@ -93,7 +96,6 @@ export default class SearchForm extends React.Component {
 
     return (
       <SearchTypeahead
-        crowi={this.props.crowi}
         onChange={this.onChange}
         onSubmit={this.props.onSubmit}
         onInputChange={this.props.onInputChange}
@@ -108,9 +110,17 @@ export default class SearchForm extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchFormWrapper = (props) => {
+  return createSubscribedElement(SearchForm, props, [AppContainer]);
+};
+
 SearchForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   keyword: PropTypes.string,
   onSubmit: PropTypes.func.isRequired,
   onInputChange: PropTypes.func,
@@ -119,3 +129,5 @@ SearchForm.propTypes = {
 SearchForm.defaultProps = {
   onInputChange: () => {},
 };
+
+export default SearchFormWrapper;

+ 14 - 7
src/client/js/components/SearchPage.js

@@ -4,6 +4,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
 import SearchPageForm from './SearchPage/SearchPageForm';
 import SearchResult from './SearchPage/SearchResult';
 
@@ -69,7 +72,7 @@ class SearchPage extends React.Component {
       searchingKeyword: keyword,
     });
 
-    this.props.crowi.apiGet('/search', { q: keyword })
+    this.props.appContainer.apiGet('/search', { q: keyword })
       .then((res) => {
         this.changeURL(keyword);
 
@@ -92,14 +95,11 @@ class SearchPage extends React.Component {
         <div className="search-page-input">
           <SearchPageForm
             t={this.props.t}
-            crowi={this.props.crowi}
             onSearchFormChanged={this.search}
             keyword={this.state.searchingKeyword}
           />
         </div>
         <SearchResult
-          crowi={this.props.crowi}
-          crowiRenderer={this.props.crowiRenderer}
           pages={this.state.searchedPages}
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
@@ -110,10 +110,17 @@ class SearchPage extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchPageWrapper = (props) => {
+  return createSubscribedElement(SearchPage, props, [AppContainer]);
+};
+
 SearchPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   query: PropTypes.object,
 };
 SearchPage.defaultProps = {
@@ -121,4 +128,4 @@ SearchPage.defaultProps = {
   query: SearchPage.getQueryByLocation(window.location || {}),
 };
 
-export default withTranslation()(SearchPage);
+export default withTranslation()(SearchPageWrapper);

+ 15 - 3
src/client/js/components/SearchPage/SearchPageForm.js

@@ -5,10 +5,13 @@ import FormGroup from 'react-bootstrap/es/FormGroup';
 import Button from 'react-bootstrap/es/Button';
 import InputGroup from 'react-bootstrap/es/InputGroup';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 import SearchForm from '../SearchForm';
 
 // Search.SearchForm
-export default class SearchPageForm extends React.Component {
+class SearchPageForm extends React.Component {
 
   constructor(props) {
     super(props);
@@ -38,7 +41,6 @@ export default class SearchPageForm extends React.Component {
         <InputGroup>
           <SearchForm
             t={this.props.t}
-            crowi={this.props.crowi}
             onSubmit={this.search}
             keyword={this.state.searchedKeyword}
             onInputChange={this.onInputChange}
@@ -55,11 +57,21 @@ export default class SearchPageForm extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchPageFormWrapper = (props) => {
+  return createSubscribedElement(SearchPageForm, props, [AppContainer]);
+};
+
 SearchPageForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   keyword: PropTypes.string,
   onSearchFormChanged: PropTypes.func.isRequired,
 };
 SearchPageForm.defaultProps = {
 };
+
+export default SearchPageFormWrapper;

+ 26 - 22
src/client/js/components/SearchPage/SearchResult.js

@@ -7,9 +7,10 @@ import * as toastr from 'toastr';
 import Page from '../PageList/Page';
 import SearchResultList from './SearchResultList';
 import DeletePageListModal from './DeletePageListModal';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
 
-// Search.SearchResult
-export default class SearchResult extends React.Component {
+class SearchResult extends React.Component {
 
   constructor(props) {
     super(props);
@@ -117,7 +118,8 @@ export default class SearchResult extends React.Component {
       return new Promise((resolve, reject) => {
         const pageId = page._id;
         const revisionId = page.revision._id;
-        this.props.crowi.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
+
+        this.props.appContainer.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
           .then((res) => {
             if (res.ok) {
               this.state.selectedPages.delete(page);
@@ -171,10 +173,6 @@ export default class SearchResult extends React.Component {
   }
 
   render() {
-    const excludePathString = this.props.tree;
-
-    // console.log(this.props.searchError);
-    // console.log(this.isError());
     if (this.isError()) {
       return (
         <div className="content-main">
@@ -189,7 +187,7 @@ export default class SearchResult extends React.Component {
 
     if (this.isNotFound()) {
       let under = '';
-      if (this.props.tree !== '') {
+      if (this.props.tree != null) {
         under = ` under "${this.props.tree}"`;
       }
       return (
@@ -249,18 +247,17 @@ export default class SearchResult extends React.Component {
           page={page}
           linkTo={pageId}
           key={page._id}
-          excludePathString={excludePathString}
         >
           { this.state.deletionMode
             && (
-            <input
-              type="checkbox"
-              className="search-result-list-delete-checkbox"
-              value={pageId}
-              checked={this.state.selectedPages.has(page)}
-              onClick={() => { return this.toggleCheckbox(page) }}
-            />
-)
+              <input
+                type="checkbox"
+                className="search-result-list-delete-checkbox"
+                value={pageId}
+                checked={this.state.selectedPages.has(page)}
+                onClick={() => { return this.toggleCheckbox(page) }}
+              />
+            )
             }
           <div className="page-list-option">
             <a href={page.path}><i className="icon-login" /></a>
@@ -300,8 +297,6 @@ export default class SearchResult extends React.Component {
           </div>
           <div className="col-md-8 search-result-content" id="search-result-content">
             <SearchResultList
-              crowi={this.props.crowi}
-              crowiRenderer={this.props.crowiRenderer}
               pages={this.props.pages}
               searchingKeyword={this.props.searchingKeyword}
             />
@@ -322,15 +317,24 @@ export default class SearchResult extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultWrapper = (props) => {
+  return createSubscribedElement(SearchResult, props, [AppContainer]);
+};
+
 SearchResult.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object,
-  tree: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
   searchError: PropTypes.object,
+  tree: PropTypes.string,
 };
 SearchResult.defaultProps = {
   searchError: null,
 };
+
+export default SearchResultWrapper;

+ 16 - 8
src/client/js/components/SearchPage/SearchResultList.js

@@ -1,16 +1,16 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import GrowiRenderer from '../../util/GrowiRenderer';
-
 import RevisionLoader from '../Page/RevisionLoader';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
 
-export default class SearchResultList extends React.Component {
+class SearchResultList extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, { mode: 'searchresult' });
+    this.growiRenderer = this.props.appContainer.getRenderer('searchresult');
   }
 
   render() {
@@ -22,8 +22,7 @@ export default class SearchResultList extends React.Component {
             <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
           )}
           <RevisionLoader
-            crowi={this.props.crowi}
-            crowiRenderer={this.growiRenderer}
+            growiRenderer={this.growiRenderer}
             pageId={page._id}
             pagePath={page.path}
             revisionId={page.revision}
@@ -42,12 +41,21 @@ export default class SearchResultList extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultListWrapper = (props) => {
+  return createSubscribedElement(SearchResultList, props, [AppContainer]);
+};
+
 SearchResultList.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
 };
 
 SearchResultList.defaultProps = {
 };
+
+export default SearchResultListWrapper;

+ 15 - 4
src/client/js/components/SearchTypeahead.js

@@ -7,8 +7,10 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import UserPicture from './User/UserPicture';
 import PageListMeta from './PageList/PageListMeta';
 import PagePath from './PageList/PagePath';
+import AppContainer from '../services/AppContainer';
+import { createSubscribedElement } from './UnstatedUtils';
 
-export default class SearchTypeahead extends React.Component {
+class SearchTypeahead extends React.Component {
 
   constructor(props) {
 
@@ -20,7 +22,6 @@ export default class SearchTypeahead extends React.Component {
       isLoading: false,
       searchError: null,
     };
-    this.crowi = this.props.crowi;
 
     this.restoreInitialData = this.restoreInitialData.bind(this);
     this.search = this.search.bind(this);
@@ -68,7 +69,7 @@ export default class SearchTypeahead extends React.Component {
 
     this.setState({ isLoading: true });
 
-    this.crowi.apiGet('/search', { q: keyword })
+    this.props.appContainer.apiGet('/search', { q: keyword })
       .then((res) => { this.onSearchSuccess(res) })
       .catch((err) => { this.onSearchError(err) });
   }
@@ -205,11 +206,19 @@ export default class SearchTypeahead extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchTypeaheadWrapper = (props) => {
+  return createSubscribedElement(SearchTypeahead, props, [AppContainer]);
+};
+
 /**
  * Properties
  */
 SearchTypeahead.propTypes = {
-  crowi:           PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onSearchSuccess: PropTypes.func,
   onSearchError:   PropTypes.func,
   onChange:        PropTypes.func,
@@ -234,3 +243,5 @@ SearchTypeahead.defaultProps = {
   keywordOnInit:   '',
   onInputChange: () => {},
 };
+
+export default SearchTypeaheadWrapper;

+ 22 - 38
src/client/js/components/SlackNotification.jsx

@@ -15,62 +15,50 @@ export default class SlackNotification extends React.Component {
   constructor(props) {
     super(props);
 
-    this.state = {
-      isSlackEnabled: this.props.isSlackEnabled,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.setState({
-      isSlackEnabled: nextProps.isSlackEnabled,
-      slackChannels: nextProps.slackChannels,
-    });
+    this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
+    this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
   }
 
-  getCurrentOptionsToSave() {
-    return Object.assign({}, this.state);
+  updateCheckboxHandler(event) {
+    const value = event.target.checked;
+    if (this.props.onEnabledFlagChange != null) {
+      this.props.onEnabledFlagChange(value);
+    }
   }
 
-  updateState(value) {
-    this.setState({ slackChannels: value });
-    // dispatch event
+  updateSlackChannelsHandler(event) {
+    const value = event.target.value;
     if (this.props.onChannelChange != null) {
       this.props.onChannelChange(value);
     }
   }
 
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isSlackEnabled: value });
-    // dispatch event
-    if (this.props.onEnabledFlagChange != null) {
-      this.props.onEnabledFlagChange(value);
-    }
-  }
-
   render() {
     return (
       <div className="input-group input-group-sm input-group-slack extended-setting">
         <label className="input-group-addon">
           <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
           <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
-          <input type="checkbox" value="1" checked={this.state.isSlackEnabled} onChange={this.updateStateCheckbox} />
+
+          <input
+            type="checkbox"
+            value="1"
+            checked={this.props.isSlackEnabled}
+            onChange={this.updateCheckboxHandler}
+          />
+
         </label>
         <input
           className="form-control"
           type="text"
-          value={this.state.slackChannels}
+          value={this.props.slackChannels}
           placeholder="slack channel name"
           data-toggle="popover"
           title="Slack通知"
           data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
           data-trigger="focus"
           data-placement="top"
-          onChange={(e) => { return this.updateState(e.target.value) }}
+          onChange={this.updateSlackChannelsHandler}
         />
       </div>
     );
@@ -79,12 +67,8 @@ export default class SlackNotification extends React.Component {
 }
 
 SlackNotification.propTypes = {
-  isSlackEnabled: PropTypes.bool,
-  slackChannels: PropTypes.string,
-  onChannelChange: PropTypes.func,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
   onEnabledFlagChange: PropTypes.func,
-};
-
-SlackNotification.defaultProps = {
-  slackChannels: '',
+  onChannelChange: PropTypes.func,
 };

+ 61 - 0
src/client/js/components/UnstatedUtils.jsx

@@ -0,0 +1,61 @@
+/* eslint-disable import/prefer-default-export */
+
+import React from 'react';
+import { Subscribe } from 'unstated';
+
+/**
+ * generate K/V object by specified instances
+ *
+ * @param {Array<object>} instances
+ * @returns automatically named key and value
+ *   e.g.
+ *   {
+ *     appContainer: <AppContainer />,
+ *     exampleContainer: <ExampleContainer />,
+ *   }
+ */
+function generateAutoNamedProps(instances) {
+  const props = {};
+
+  instances.forEach((instance) => {
+    // get class name
+    const className = instance.constructor.name;
+    // convert initial charactor to lower case
+    const propName = `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
+
+    props[propName] = instance;
+  });
+
+  return props;
+}
+
+/**
+ * create React component instance that is injected specified containers
+ *
+ * @param {object} componentClass wrapped React.Component class
+ * @param {*} props
+ * @param {*} containerClasses unstated container classes to subscribe
+ * @returns returns such like a following element:
+ *  e.g.
+ *  <Subscribe to={containerClasses}>  // containerClasses = [AppContainer, PageContainer]
+ *    { (appContainer, pageContainer) => (
+ *      <Component appContainer={appContainer} pageContainer={pageContainer} {...this.props} />
+ *    )}
+ *  </Subscribe>
+ */
+export function createSubscribedElement(componentClass, props, containerClasses) {
+  return (
+    // wrap with <Subscribe></Subscribe>
+    <Subscribe to={containerClasses}>
+      { (...containers) => {
+        const propsForContainers = generateAutoNamedProps(containers);
+
+        return React.createElement(
+          componentClass,
+          Object.assign(propsForContainers, props),
+        );
+      }}
+    </Subscribe>
+  );
+
+}

+ 16 - 3
src/client/js/components/User/UserPictureList.jsx

@@ -4,9 +4,12 @@ import PropTypes from 'prop-types';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 import UserPicture from './UserPicture';
 
-export default class UserPictureList extends React.Component {
+class UserPictureList extends React.Component {
 
   constructor(props) {
     super(props);
@@ -15,7 +18,7 @@ export default class UserPictureList extends React.Component {
 
     const users = this.props.users.concat(
       // FIXME: user data cache
-      this.props.crowi.findUserByIds(userIds),
+      this.props.appContainer.findUserByIds(userIds),
     );
 
     this.state = {
@@ -47,8 +50,16 @@ export default class UserPictureList extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const UserPictureListWrapper = (props) => {
+  return createSubscribedElement(UserPictureList, props, [AppContainer]);
+};
+
 UserPictureList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   userIds: PropTypes.arrayOf(PropTypes.string),
   users: PropTypes.arrayOf(PropTypes.object),
 };
@@ -57,3 +68,5 @@ UserPictureList.defaultProps = {
   userIds: [],
   users: [],
 };
+
+export default UserPictureListWrapper;

+ 1 - 1
src/client/js/installer.js → src/client/js/installer.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 
-import i18nFactory from './i18n';
+import i18nFactory from './util/i18n';
 
 import InstallerForm from './components/InstallerForm';
 

+ 35 - 37
src/client/js/legacy/crowi.js

@@ -4,6 +4,8 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 
+import { Provider } from 'unstated';
+
 import { debounce } from 'throttle-debounce';
 
 import { pathUtils } from 'growi-commons';
@@ -55,14 +57,15 @@ Crowi.setCaretLineAndFocusToEditor = function() {
     return;
   }
 
-  const crowi = window.crowi;
+  const { appContainer } = window;
+  const editorContainer = appContainer.getContainer('EditorContainer');
   const line = pageEditorDom.getAttribute('data-caret-line') || 0;
-  crowi.setCaretLine(+line);
+  editorContainer.setCaretLine(+line);
   // reset data-caret-line attribute
   pageEditorDom.removeAttribute('data-caret-line');
 
   // focus
-  crowi.focusToEditor();
+  editorContainer.focusToEditor();
 };
 
 // original: middleware.swigFilter
@@ -251,26 +254,10 @@ Crowi.highlightSelectedSection = function(hash) {
   }
 };
 
-/**
- * Return editor mode string
- * @return 'builtin' or 'hackmd' or null (not editing)
- */
-Crowi.getCurrentEditorMode = function() {
-  const isEditing = $('body').hasClass('on-edit');
-  if (!isEditing) {
-    return null;
-  }
-
-  if ($('body').hasClass('builtin-editor')) {
-    return 'builtin';
-  }
-
-  return 'hackmd';
-};
-
 $(() => {
-  const crowi = window.crowi;
-  const config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+  const appContainer = window.appContainer;
+  const websocketContainer = appContainer.getContainer('WebsocketContainer');
+  const config = appContainer.getConfig();
 
   const pageId = $('#content-main').data('page-id');
   // const revisionId = $('#content-main').data('page-revision-id');
@@ -357,7 +344,7 @@ $(() => {
     $(this).serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is renamed page path
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
     $.ajax({
       type: 'POST',
@@ -395,7 +382,7 @@ $(() => {
     $(this).serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is duplicated page path
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
     $.ajax({
       type: 'POST',
@@ -431,7 +418,7 @@ $(() => {
     $('#delete-page-form').serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value;
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
     $.ajax({
       type: 'POST',
@@ -547,9 +534,7 @@ $(() => {
     const isShown = $('#view-timeline').data('shown');
 
     if (growiRendererForTimeline == null) {
-      const crowi = window.crowi;
-      const crowiRenderer = window.crowiRenderer;
-      growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, { mode: 'timeline' });
+      growiRendererForTimeline = GrowiRenderer.generate('timeline');
     }
 
     if (isShown === 0) {
@@ -564,14 +549,15 @@ $(() => {
         const revisionId = timelineElm.getAttribute('data-revision');
 
         ReactDOM.render(
-          <RevisionLoader
-            lazy
-            crowi={crowi}
-            crowiRenderer={growiRendererForTimeline}
-            pageId={pageId}
-            pagePath={pagePath}
-            revisionId={revisionId}
-          />,
+          <Provider inject={[appContainer]}>
+            <RevisionLoader
+              lazy
+              growiRenderer={growiRendererForTimeline}
+              pageId={pageId}
+              pagePath={pagePath}
+              revisionId={revisionId}
+            />
+          </Provider>,
           revisionBodyElem,
         );
       });
@@ -587,7 +573,8 @@ $(() => {
       const templateId = $(this).data('template');
       const template = $(`#${templateId}`).html();
 
-      crowi.saveDraft(path, template);
+      const editorContainer = appContainer.getContainer('EditorContainer');
+      editorContainer.saveDraft(path, template);
       top.location.href = `${path}#edit`;
     });
 
@@ -625,7 +612,11 @@ $(() => {
   } // end if pageId
 
   // tab changing handling
+  $('a[href="#revision-body"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: null });
+  });
   $('a[href="#edit"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: 'builtin' });
     $('body').addClass('on-edit');
     $('body').addClass('builtin-editor');
   });
@@ -634,6 +625,7 @@ $(() => {
     $('body').removeClass('builtin-editor');
   });
   $('a[href="#hackmd"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: 'hackmd' });
     $('body').addClass('on-edit');
     $('body').addClass('hackmd');
   });
@@ -689,9 +681,13 @@ $(() => {
 });
 
 window.addEventListener('load', (e) => {
+  const { appContainer } = window;
+
   // hash on page
   if (location.hash) {
     if ((location.hash === '#edit' || location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
+      appContainer.setState({ editorMode: 'builtin' });
+
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('builtin-editor');
@@ -700,6 +696,8 @@ window.addEventListener('load', (e) => {
       Crowi.setCaretLineAndFocusToEditor();
     }
     else if (location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
+      appContainer.setState({ editorMode: 'hackmd' });
+
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');

+ 6 - 6
src/client/js/plugin.js

@@ -2,17 +2,17 @@ import loggerFactory from '@alias/logger';
 
 const logger = loggerFactory('growi:plugin');
 
-export default class CrowiPlugin {
+export default class GrowiPlugin {
 
   /**
    * process plugin entry
    *
-   * @param {Crowi} crowi Crowi context class
-   * @param {CrowiRenderer} crowiRenderer CrowiRenderer
+   * @param {AppContainer} appContainer
+   * @param {GrowiRenderer} originRenderer The origin instance of GrowiRenderer
    *
    * @memberof CrowiPlugin
    */
-  installAll(crowi, crowiRenderer) {
+  installAll(appContainer, originRenderer) {
     // import plugin definitions
     let definitions = [];
     try {
@@ -34,7 +34,7 @@ export default class CrowiPlugin {
         // v2 or above
         default:
           definition.entries.forEach((entry) => {
-            entry(crowi, crowiRenderer);
+            entry(appContainer, originRenderer);
           });
       }
     });
@@ -43,4 +43,4 @@ export default class CrowiPlugin {
 
 }
 
-window.crowiPlugin = new CrowiPlugin(); // FIXME
+window.growiPlugin = new GrowiPlugin();

+ 148 - 104
src/client/js/util/Crowi.js → src/client/js/services/AppContainer.js

@@ -1,37 +1,69 @@
-/**
- * Crowi context class for client
- */
+import { Container } from 'unstated';
 
 import axios from 'axios';
-import io from 'socket.io-client';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
-import emojiStrategy from './emojione/emoji_strategy_shrinked.json';
+import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
+import GrowiRenderer from '../util/GrowiRenderer';
 
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
-} from './interceptor/detach-code-blocks';
+} from '../util/interceptor/detach-code-blocks';
+
+import i18nFactory from '../util/i18n';
+
+/**
+ * Service container related to options for Application
+ * @extends {Container} unstated Container
+ */
+export default class AppContainer extends Container {
 
-export default class Crowi {
+  constructor() {
+    super();
 
-  constructor(context, window) {
-    this.context = context;
-    this.config = {};
+    this.state = {
+      editorMode: null,
+    };
+
+    const body = document.querySelector('body');
+
+    this.me = body.dataset.currentUsername;
+    this.isAdmin = body.dataset.isAdmin === 'true';
+    this.csrfToken = body.dataset.csrftoken;
+    this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
+    this.isLoggedin = document.querySelector('.main-container.nologin') == null;
+
+    this.config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
 
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
 
-    this.window = window;
-    this.location = window.location || {};
-    this.document = window.document || {};
-    this.localStorage = window.localStorage || {};
-    this.socketClientId = Math.floor(Math.random() * 100000);
-    this.page = undefined;
-    this.pageEditor = undefined;
     this.isDocSaved = true;
 
+    this.originRenderer = new GrowiRenderer(this);
+
+    this.interceptorManager = new InterceptorManager();
+    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
+    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
+
+    const userlang = body.dataset.userlang;
+    this.i18n = i18nFactory(userlang);
+
+    this.users = [];
+    this.userByName = {};
+    this.userById = {};
+    this.recoverData();
+
+    if (this.isLoggedin) {
+      this.fetchUsers();
+    }
+
+    this.containerInstances = {};
+    this.componentInstances = {};
+    this.rendererInstances = {};
+
     this.fetchUsers = this.fetchUsers.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiPost = this.apiPost.bind(this);
@@ -42,25 +74,25 @@ export default class Crowi {
       put: this.apiv3Put.bind(this),
       delete: this.apiv3Delete.bind(this),
     };
+  }
 
-    this.interceptorManager = new InterceptorManager();
-    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
-    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
-
-    // FIXME
-    this.me = context.me;
-    this.isAdmin = context.isAdmin;
-    this.csrfToken = context.csrfToken;
+  initPlugins() {
+    if (this.isPluginEnabled) {
+      const growiPlugin = window.growiPlugin;
+      growiPlugin.installAll(this, this.originRenderer);
+    }
+  }
 
-    this.users = [];
-    this.userByName = {};
-    this.userById = {};
-    this.draft = {};
-    this.editorOptions = {};
+  injectToWindow() {
+    window.appContainer = this;
 
-    this.recoverData();
+    const originRenderer = this.getOriginRenderer();
+    window.growiRenderer = originRenderer;
 
-    this.socket = io();
+    // backward compatibility
+    window.crowi = this;
+    window.crowiRenderer = originRenderer;
+    window.crowiPlugin = window.growiPlugin;
   }
 
   /**
@@ -70,36 +102,93 @@ export default class Crowi {
     return window.Crowi;
   }
 
-  setConfig(config) {
-    this.config = config;
-  }
-
   getConfig() {
     return this.config;
   }
 
-  setPage(page) {
-    this.page = page;
+  /**
+   * Register unstated container instance
+   * @param {object} instance unstated container instance
+   */
+  registerContainer(instance) {
+    if (instance == null) {
+      throw new Error('The specified instance must not be null');
+    }
+
+    const className = instance.constructor.name;
+
+    if (this.containerInstances[className] != null) {
+      throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
+    }
+
+    this.containerInstances[className] = instance;
   }
 
-  setPageEditor(pageEditor) {
-    this.pageEditor = pageEditor;
+  /**
+   * Get registered unstated container instance
+   * !! THIS METHOD SHOULD ONLY BE USED FROM unstated CONTAINERS !!
+   * !! From component instances, inject containers with `import { Subscribe } from 'unstated'` !!
+   *
+   * @param {string} className
+   */
+  getContainer(className) {
+    return this.containerInstances[className];
   }
 
-  setIsDocSaved(isSaved) {
-    this.isDocSaved = isSaved;
+  /**
+   * Register React component instance
+   * @param {object} instance React component instance
+   */
+  registerComponentInstance(instance) {
+    if (instance == null) {
+      throw new Error('The specified instance must not be null');
+    }
+
+    const className = instance.constructor.name;
+
+    if (this.componentInstances[className] != null) {
+      throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
+    }
+
+    this.componentInstances[className] = instance;
   }
 
-  getIsDocSaved() {
-    return this.isDocSaved;
+  /**
+   * Get registered React component instance
+   * @param {string} className
+   */
+  getComponentInstance(className) {
+    return this.componentInstances[className];
   }
 
-  getWebSocket() {
-    return this.socket;
+  getOriginRenderer() {
+    return this.originRenderer;
+  }
+
+  /**
+   * factory method
+   */
+  getRenderer(mode) {
+    if (this.rendererInstances[mode] != null) {
+      return this.rendererInstances[mode];
+    }
+
+    const renderer = new GrowiRenderer(this, this.originRenderer);
+    // setup
+    renderer.initMarkdownItConfigurers(mode);
+    renderer.setup(mode);
+    // register
+    this.rendererInstances[mode] = renderer;
+
+    return renderer;
+  }
+
+  setIsDocSaved(isSaved) {
+    this.isDocSaved = isSaved;
   }
 
-  getSocketClientId() {
-    return this.socketClientId;
+  getIsDocSaved() {
+    return this.isDocSaved;
   }
 
   getEmojiStrategy() {
@@ -111,19 +200,16 @@ export default class Crowi {
       'userByName',
       'userById',
       'users',
-      'draft',
-      'editorOptions',
-      'previewOptions',
     ];
 
     keys.forEach((key) => {
-      const keyContent = this.localStorage[key];
+      const keyContent = window.localStorage[key];
       if (keyContent) {
         try {
           this[key] = JSON.parse(keyContent);
         }
         catch (e) {
-          this.localStorage.removeItem(key);
+          window.localStorage.removeItem(key);
         }
       }
     });
@@ -132,14 +218,14 @@ export default class Crowi {
   fetchUsers() {
     const interval = 1000 * 60 * 15; // 15min
     const currentTime = new Date();
-    if (this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
+    if (window.localStorage.lastFetched && interval > currentTime - new Date(window.localStorage.lastFetched)) {
       return;
     }
 
     this.apiGet('/users.list', {})
       .then((data) => {
         this.users = data.users;
-        this.localStorage.users = JSON.stringify(data.users);
+        window.localStorage.users = JSON.stringify(data.users);
 
         const userByName = {};
         const userById = {};
@@ -149,61 +235,19 @@ export default class Crowi {
           userById[user._id] = user;
         }
         this.userByName = userByName;
-        this.localStorage.userByName = JSON.stringify(userByName);
+        window.localStorage.userByName = JSON.stringify(userByName);
 
         this.userById = userById;
-        this.localStorage.userById = JSON.stringify(userById);
+        window.localStorage.userById = JSON.stringify(userById);
 
-        this.localStorage.lastFetched = new Date();
+        window.localStorage.lastFetched = new Date();
       })
       .catch((err) => {
-        this.localStorage.removeItem('lastFetched');
+        window.localStorage.removeItem('lastFetched');
       // ignore errors
       });
   }
 
-  setCaretLine(line) {
-    if (this.pageEditor != null) {
-      this.pageEditor.setCaretLine(line);
-    }
-  }
-
-  focusToEditor() {
-    if (this.pageEditor != null) {
-      this.pageEditor.focusToEditor();
-    }
-  }
-
-  clearDraft(path) {
-    delete this.draft[path];
-    this.localStorage.setItem('draft', JSON.stringify(this.draft));
-  }
-
-  clearAllDrafts() {
-    this.localStorage.removeItem('draft');
-  }
-
-  saveDraft(path, body) {
-    this.draft[path] = body;
-    this.localStorage.setItem('draft', JSON.stringify(this.draft));
-  }
-
-  findDraft(path) {
-    if (this.draft && this.draft[path]) {
-      return this.draft[path];
-    }
-
-    return null;
-  }
-
-  saveEditorOptions(options) {
-    this.localStorage.setItem('editorOptions', JSON.stringify(options));
-  }
-
-  savePreviewOptions(options) {
-    this.localStorage.setItem('previewOptions', JSON.stringify(options));
-  }
-
   findUserById(userId) {
     if (this.userById && this.userById[userId]) {
       return this.userById[userId];
@@ -242,7 +286,7 @@ export default class Crowi {
         if (!res.ok) {
           throw new Error(res.error);
         }
-        return res.page;
+        return { page: res.page, tags: res.tags };
       });
   }
 
@@ -257,7 +301,7 @@ export default class Crowi {
         if (!res.ok) {
           throw new Error(res.error);
         }
-        return res.page;
+        return { page: res.page, tags: res.tags };
       });
   }
 
@@ -265,7 +309,7 @@ export default class Crowi {
     let targetComponent;
     switch (componentKind) {
       case 'page':
-        targetComponent = this.page;
+        targetComponent = this.getComponentInstance('Page');
         break;
     }
     targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);

+ 117 - 0
src/client/js/services/CommentContainer.js

@@ -0,0 +1,117 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:CommentContainer');
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {Container} unstated Container
+ */
+export default class CommentContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const mainContent = document.querySelector('#content-main');
+
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    this.state = {
+      comments: [],
+
+      // settings shared among all of CommentEditor
+      isSlackEnabled: false,
+      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
+    };
+
+    this.retrieveComments = this.retrieveComments.bind(this);
+  }
+
+  getPageContainer() {
+    return this.appContainer.getContainer('PageContainer');
+  }
+
+  findAndSplice(comment) {
+    const comments = this.state.comments;
+
+    const index = comments.indexOf(comment);
+    if (index < 0) {
+      return;
+    }
+    comments.splice(index, 1);
+
+    this.setState({ comments });
+  }
+
+  /**
+   * Load data of comments and store them in state
+   */
+  retrieveComments() {
+    const { pageId } = this.getPageContainer().state;
+
+    // get data (desc order array)
+    return this.appContainer.apiGet('/comments.get', { page_id: pageId })
+      .then((res) => {
+        if (res.ok) {
+          this.setState({ comments: res.comments });
+        }
+      });
+  }
+
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
+    const { pageId, revisionId } = this.getPageContainer().state;
+
+    return this.appContainer.apiPost('/comments.add', {
+      commentForm: {
+        comment,
+        page_id: pageId,
+        revision_id: revisionId,
+        is_markdown: isMarkdown,
+        replyTo,
+      },
+      slackNotificationForm: {
+        isSlackEnabled,
+        slackChannels,
+      },
+    })
+      .then((res) => {
+        if (res.ok) {
+          return this.retrieveComments();
+        }
+      });
+  }
+
+  deleteComment(comment) {
+    return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
+      .then((res) => {
+        if (res.ok) {
+          this.findAndSplice(comment);
+        }
+      });
+  }
+
+  uploadAttachment(file) {
+    const { pageId, pagePath } = this.getPageContainer().state;
+
+    const endpoint = '/attachments.add';
+    const formData = new FormData();
+    formData.append('file', file);
+    formData.append('path', pagePath);
+    formData.append('page_id', pageId);
+
+    return this.appContainer.apiPost(endpoint, formData);
+  }
+
+}

+ 163 - 0
src/client/js/services/EditorContainer.js

@@ -0,0 +1,163 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:EditorContainer');
+
+/**
+ * Service container related to options for Editor/Preview
+ * @extends {Container} unstated Container
+ */
+export default class EditorContainer extends Container {
+
+  constructor(appContainer, defaultEditorOptions, defaultPreviewOptions) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const mainContent = document.querySelector('#content-main');
+
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    this.state = {
+      tags: [],
+
+      isSlackEnabled: false,
+      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
+
+      grant: 1, // default: public
+      grantGroupId: null,
+      grantGroupName: null,
+
+      editorOptions: {},
+      previewOptions: {},
+    };
+
+    this.initStateGrant();
+    this.initDrafts();
+
+    this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
+    this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
+  }
+
+  /**
+   * initialize state for page permission
+   */
+  initStateGrant() {
+    const elem = document.getElementById('save-page-controls');
+
+    if (elem) {
+      this.state.grant = +elem.dataset.grant;
+
+      const grantGroupId = elem.dataset.grantGroup;
+      if (grantGroupId != null && grantGroupId.length > 0) {
+        this.state.grantGroupId = grantGroupId;
+        this.state.grantGroupName = elem.dataset.grantGroupName;
+      }
+    }
+  }
+
+  /**
+   * initialize state for drafts
+   */
+  initDrafts() {
+    this.drafts = {};
+
+    // restore data from localStorage
+    const contents = window.localStorage.drafts;
+    if (contents != null) {
+      try {
+        this.drafts = JSON.parse(contents);
+      }
+      catch (e) {
+        window.localStorage.removeItem('drafts');
+      }
+    }
+
+    if (this.state.pageId == null) {
+      const draft = this.findDraft(this.state.path);
+      if (draft != null) {
+        this.state.markdown = draft;
+      }
+    }
+  }
+
+  initEditorOptions(stateKey, localStorageKey, defaultOptions) {
+    // load from localStorage
+    const optsStr = window.localStorage[localStorageKey];
+
+    let loadedOpts = {};
+    // JSON.parseparse
+    if (optsStr != null) {
+      try {
+        loadedOpts = JSON.parse(optsStr);
+      }
+      catch (e) {
+        this.localStorage.removeItem(localStorageKey);
+      }
+    }
+
+    // set to state obj
+    this.state[stateKey] = Object.assign(defaultOptions, loadedOpts);
+  }
+
+  saveOptsToLocalStorage() {
+    window.localStorage.setItem('editorOptions', JSON.stringify(this.state.editorOptions));
+    window.localStorage.setItem('previewOptions', JSON.stringify(this.state.previewOptions));
+  }
+
+  setCaretLine(line) {
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      pageEditor.setCaretLine(line);
+    }
+  }
+
+  focusToEditor() {
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      pageEditor.focusToEditor();
+    }
+  }
+
+  getCurrentOptionsToSave() {
+    const opt = {
+      isSlackEnabled: this.state.isSlackEnabled,
+      slackChannels: this.state.slackChannels,
+      grant: this.state.grant,
+    };
+
+    if (this.state.grantGroupId != null) {
+      opt.grantUserGroupId = this.state.grantGroupId;
+    }
+
+    return opt;
+  }
+
+  clearDraft(path) {
+    delete this.drafts[path];
+    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
+  }
+
+  clearAllDrafts() {
+    window.localStorage.removeItem('drafts');
+  }
+
+  saveDraft(path, body) {
+    this.drafts[path] = body;
+    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
+  }
+
+  findDraft(path) {
+    if (this.drafts != null && this.drafts[path]) {
+      return this.drafts[path];
+    }
+
+    return null;
+  }
+
+}

+ 173 - 0
src/client/js/services/PageContainer.js

@@ -0,0 +1,173 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import * as entities from 'entities';
+
+const logger = loggerFactory('growi:services:PageContainer');
+
+/**
+ * Service container related to Page
+ * @extends {Container} unstated Container
+ */
+export default class PageContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.state = {};
+
+    const mainContent = document.querySelector('#content-main');
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    const revisionId = mainContent.getAttribute('data-page-revision-id');
+
+    this.state = {
+      // local page data
+      markdown: null, // will be initialized after initStateMarkdown()
+      pageId: mainContent.getAttribute('data-page-id'),
+      revisionId,
+      revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
+      path: mainContent.getAttribute('data-path'),
+      isLiked: false,
+      seenUserIds: [],
+      likerUserIds: [],
+
+      tags: [],
+      templateTagData: mainContent.getAttribute('data-template-tags') || '',
+
+      // latest(on remote) information
+      remoteRevisionId: revisionId,
+      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced'),
+      lastUpdateUsername: undefined,
+      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd'),
+      hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
+      isHackmdDraftUpdatingInRealtime: false,
+    };
+
+    this.initStateMarkdown();
+    this.initStateOthers();
+
+    this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
+    this.addWebSocketEventHandlers();
+  }
+
+  /**
+   * initialize state for markdown data
+   */
+  initStateMarkdown() {
+    let pageContent = '';
+
+    const rawText = document.getElementById('raw-text-original');
+    if (rawText) {
+      pageContent = rawText.innerHTML;
+    }
+    const markdown = entities.decodeHTML(pageContent);
+
+    this.state.markdown = markdown;
+  }
+
+  initStateOthers() {
+    const likeButtonElem = document.getElementById('like-button');
+    if (likeButtonElem != null) {
+      this.state.isLiked = likeButtonElem.dataset.liked === 'true';
+    }
+
+    const seenUserListElem = document.getElementById('seen-user-list');
+    if (seenUserListElem != null) {
+      const userIdsStr = seenUserListElem.dataset.userIds;
+      this.state.seenUserIds = userIdsStr.split(',');
+    }
+
+
+    const likerListElem = document.getElementById('liker-list');
+    if (likerListElem != null) {
+      const userIdsStr = likerListElem.dataset.userIds;
+      this.state.likerUserIds = userIdsStr.split(',');
+    }
+  }
+
+  setLatestRemotePageData(page, user) {
+    this.setState({
+      remoteRevisionId: page.revision._id,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      lastUpdateUsername: user.name,
+    });
+  }
+
+  addWebSocketEventHandlers() {
+    const pageContainer = this;
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socket = websocketContainer.getWebSocket();
+
+    socket.on('page:create', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
+
+      // update PageStatusAlert
+      if (data.page.path === pageContainer.state.path) {
+        this.setLatestRemotePageData(data.page, data.user);
+      }
+    });
+
+    socket.on('page:update', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+
+      if (data.page.path === pageContainer.state.path) {
+        // update PageStatusAlert
+        pageContainer.setLatestRemotePageData(data.page, data.user);
+        // update remote data
+        const page = data.page;
+        pageContainer.setState({
+          remoteRevisionId: page.revision._id,
+          revisionIdHackmdSynced: page.revisionHackmdSynced,
+          hasDraftOnHackmd: page.hasDraftOnHackmd,
+        });
+      }
+    });
+
+    socket.on('page:delete', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
+
+      // update PageStatusAlert
+      if (data.page.path === pageContainer.state.path) {
+        pageContainer.setLatestRemotePageData(data.page, data.user);
+      }
+    });
+
+    socket.on('page:editingWithHackmd', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
+
+      if (data.page.path === pageContainer.state.path) {
+        pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
+      }
+    });
+
+  }
+
+}

+ 54 - 0
src/client/js/services/TagContainer.js

@@ -0,0 +1,54 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:TagContainer');
+
+/**
+ * Service container related to Tag
+ * @extends {Container} unstated Container
+ */
+export default class TagContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.init();
+  }
+
+  /**
+   * retrieve tags data
+   * !! This method should be invoked after PageContainer and EditorContainer has been initialized !!
+   */
+  async init() {
+    const pageContainer = this.appContainer.getContainer('PageContainer');
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+
+    if (Object.keys(pageContainer.state).length === 0) {
+      logger.debug('There is no need to initialize TagContainer because this is not a Page');
+      return;
+    }
+
+    const { pageId, templateTagData } = pageContainer.state;
+
+    let tags;
+    // when the page exists
+    if (pageId != null) {
+      const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });
+      tags = res.tags;
+    }
+    // when the page not exist
+    else if (templateTagData != null) {
+      tags = templateTagData.split(',');
+    }
+
+    logger.debug('tags data has been initialized');
+
+    pageContainer.setState({ tags });
+    editorContainer.setState({ tags });
+  }
+
+}

+ 33 - 0
src/client/js/services/WebsocketContainer.js

@@ -0,0 +1,33 @@
+import { Container } from 'unstated';
+
+import io from 'socket.io-client';
+
+/**
+ * Service container related to options for WebSocket
+ * @extends {Container} unstated Container
+ */
+export default class WebsocketContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.socket = io();
+    this.socketClientId = Math.floor(Math.random() * 100000);
+
+    this.state = {
+    };
+
+  }
+
+  getWebSocket() {
+    return this.socket;
+  }
+
+  getSocketClientId() {
+    return this.socketClientId;
+  }
+
+}

+ 47 - 53
src/client/js/util/GrowiRenderer.js

@@ -20,38 +20,40 @@ import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
 const logger = require('@alias/logger')('growi:util:GrowiRenderer');
 
-
 export default class GrowiRenderer {
 
   /**
    *
-   * @param {Crowi} crowi
-   * @param {GrowiRenderer} originRenderer may be customized by plugins
-   * @param {object} options
+   * @param {AppContainer} appContainer
+   * @param {GrowiRenderer} originRenderer
+   * @param {string} mode
    */
-  constructor(crowi, originRenderer, options) {
-    this.crowi = crowi;
-    this.originRenderer = originRenderer || {};
-    this.options = Object.assign( //  merge options
-      { isAutoSetup: true }, //       default options
-      options || {}, //               specified options
-    );
-
-    // initialize processors
-    //  that will be retrieved if originRenderer exists
-    this.preProcessors = this.originRenderer.preProcessors || [
-      new Linker(crowi),
-      new CsvToTable(crowi),
-      new XssFilter(crowi),
-    ];
-    this.postProcessors = this.originRenderer.postProcessors || [
-      new CrowiTemplate(crowi),
-    ];
+  constructor(appContainer, originRenderer) {
+    this.appContainer = appContainer;
+
+    if (originRenderer != null) {
+      this.preProcessors = originRenderer.preProcessors;
+      this.postProcessors = originRenderer.postProcessors;
+    }
+    else {
+      this.preProcessors = [
+        new Linker(appContainer),
+        new CsvToTable(appContainer),
+        new XssFilter(appContainer),
+      ];
+      this.postProcessors = [
+        new CrowiTemplate(appContainer),
+      ];
+    }
 
     this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
     this.setup = this.setup.bind(this);
     this.process = this.process.bind(this);
     this.codeRenderer = this.codeRenderer.bind(this);
+  }
+
+  initMarkdownItConfigurers(mode) {
+    const appContainer = this.appContainer;
 
     // init markdown-it
     this.md = new MarkdownIt({
@@ -59,52 +61,44 @@ export default class GrowiRenderer {
       linkify: true,
       highlight: this.codeRenderer,
     });
-    this.initMarkdownItConfigurers(options);
-
-    // auto setup
-    if (this.options.isAutoSetup) {
-      this.setup(crowi.getConfig());
-    }
-  }
-
-  initMarkdownItConfigurers(options) {
-    const crowi = this.crowi;
 
     this.isMarkdownItConfigured = false;
 
     this.markdownItConfigurers = [
-      new TaskListsConfigurer(crowi),
-      new HeaderConfigurer(crowi),
-      new EmojiConfigurer(crowi),
-      new MathJaxConfigurer(crowi),
-      new PlantUMLConfigurer(crowi),
-      new BlockdiagConfigurer(crowi),
+      new TaskListsConfigurer(appContainer),
+      new HeaderConfigurer(appContainer),
+      new EmojiConfigurer(appContainer),
+      new MathJaxConfigurer(appContainer),
+      new PlantUMLConfigurer(appContainer),
+      new BlockdiagConfigurer(appContainer),
     ];
 
     // add configurers according to mode
-    const mode = options.mode;
     switch (mode) {
-      case 'page':
+      case 'page': {
+        const renderToc = appContainer.getCrowiForJquery().renderTocContent;
+
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(crowi),
-          new TocAndAnchorConfigurer(crowi, options.renderToc),
-          new HeaderLineNumberConfigurer(crowi),
-          new HeaderWithEditLinkConfigurer(crowi),
-          new TableWithHandsontableButtonConfigurer(crowi),
+          new FooternoteConfigurer(appContainer),
+          new TocAndAnchorConfigurer(appContainer, renderToc),
+          new HeaderLineNumberConfigurer(appContainer),
+          new HeaderWithEditLinkConfigurer(appContainer),
+          new TableWithHandsontableButtonConfigurer(appContainer),
         ]);
         break;
+      }
       case 'editor':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(crowi),
-          new HeaderLineNumberConfigurer(crowi),
-          new TableConfigurer(crowi),
+          new FooternoteConfigurer(appContainer),
+          new HeaderLineNumberConfigurer(appContainer),
+          new TableConfigurer(appContainer),
         ]);
         break;
       // case 'comment':
       //   break;
       default:
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(crowi),
+          new TableConfigurer(appContainer),
         ]);
         break;
     }
@@ -113,11 +107,11 @@ export default class GrowiRenderer {
   /**
    * setup with crowi config
    */
-  setup() {
-    const crowiConfig = this.crowi.config;
+  setup(mode) {
+    const crowiConfig = this.appContainer.config;
 
     let isEnabledLinebreaks;
-    switch (this.options.mode) {
+    switch (mode) {
       case 'comment':
         isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
         break;
@@ -166,7 +160,7 @@ export default class GrowiRenderer {
   }
 
   codeRenderer(code, langExt) {
-    const config = this.crowi.getConfig();
+    const config = this.appContainer.getConfig();
     const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
 
     let citeTag = '';

+ 5 - 2
src/client/js/util/PostProcessor/CrowiTemplate.js

@@ -3,6 +3,10 @@ import dateFnsFormat from 'date-fns/format';
 export default class CrowiTemplate {
 
   constructor(crowi) {
+    this.crowi = crowi;
+
+    this.getUser = this.getUser.bind(this);
+
     this.templatePattern = {
       year: this.getYear,
       month: this.getMonth,
@@ -54,8 +58,7 @@ export default class CrowiTemplate {
   }
 
   getUser() {
-    // FIXME
-    const username = window.crowi.me || null;
+    const username = this.crowi.me || null;
 
     if (!username) {
       return '';

+ 0 - 0
src/client/js/i18n.js → src/client/js/util/i18n.js


+ 8 - 12
src/client/js/util/reveal/plugins/growi-renderer.js

@@ -1,24 +1,21 @@
-import GrowiRenderer from '../../GrowiRenderer';
-
 /**
  * reveal.js growi-renderer plugin.
  */
 (function(root, factory) {
-  // parent window DOM (crowi.js) of presentation window.
-  const parentWindow = window.parent;
-
-  // create GrowiRenderer instance and setup.
-  const growiRenderer = new GrowiRenderer(parentWindow.crowi, parentWindow.crowiRenderer, { mode: 'editor' });
+  // get AppContainer instance from parent window
+  const appContainer = window.parent.appContainer;
 
-  const growiRendererPlugin = factory(growiRenderer);
+  const growiRendererPlugin = factory(appContainer);
   growiRendererPlugin.initialize();
-}(this, (growiRenderer) => {
+}(this, (appContainer) => {
   /* eslint-disable no-useless-escape */
   const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
   const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
   const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
   /* eslint-enable no-useless-escape */
 
+  const growiRenderer = appContainer.getRenderer('editor');
+
   let marked;
 
   /**
@@ -31,7 +28,7 @@ import GrowiRenderer from '../../GrowiRenderer';
       const section = sections[i];
       const markdown = marked.getMarkdownFromSlide(section);
       const context = { markdown };
-      const interceptorManager = growiRenderer.crowi.interceptorManager;
+      const interceptorManager = appContainer.interceptorManager;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       // replace string '\n' to LF code.
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
@@ -54,7 +51,7 @@ import GrowiRenderer from '../../GrowiRenderer';
   function convertSlides() {
     const sections = document.querySelectorAll('[data-markdown]');
     let markdown;
-    const interceptorManager = growiRenderer.crowi.interceptorManager;
+    const interceptorManager = appContainer.interceptorManager;
 
     for (let i = 0, len = sections.length; i < len; i++) {
       const section = sections[i];
@@ -104,7 +101,6 @@ import GrowiRenderer from '../../GrowiRenderer';
   // API
   return {
     async initialize() {
-      growiRenderer.setup();
       marked = require('./markdown').default(growiRenderer.process);
       divideSlides();
       marked.processSlides();

+ 51 - 51
src/client/styles/bootstrap4/_alert.scss

@@ -1,51 +1,51 @@
-//
-// Base styles
-//
-
-.alert {
-  position: relative;
-  padding: $alert-padding-y $alert-padding-x;
-  margin-bottom: $alert-margin-bottom;
-  border: $alert-border-width solid transparent;
-  @include border-radius($alert-border-radius);
-}
-
-// Headings for larger alerts
-.alert-heading {
-  // Specified to prevent conflicts of changing $headings-color
-  color: inherit;
-}
-
-// Provide class for links that match alerts
-.alert-link {
-  font-weight: $alert-link-font-weight;
-}
-
-
-// Dismissible alerts
-//
-// Expand the right padding and account for the close button's positioning.
-
-.alert-dismissible {
-  padding-right: ($close-font-size + $alert-padding-x * 2);
-
-  // Adjust close link position
-  .close {
-    position: absolute;
-    top: 0;
-    right: 0;
-    padding: $alert-padding-y $alert-padding-x;
-    color: inherit;
-  }
-}
-
-
-// Alternate styles
-//
-// Generate contextual modifier classes for colorizing the alert.
-
-@each $color, $value in $theme-colors {
-  .alert-#{$color} {
-    @include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), theme-color-level($color, $alert-color-level));
-  }
-}
+//
+// Base styles
+//
+
+.alert {
+  position: relative;
+  padding: $alert-padding-y $alert-padding-x;
+  margin-bottom: $alert-margin-bottom;
+  border: $alert-border-width solid transparent;
+  @include border-radius($alert-border-radius);
+}
+
+// Headings for larger alerts
+.alert-heading {
+  // Specified to prevent conflicts of changing $headings-color
+  color: inherit;
+}
+
+// Provide class for links that match alerts
+.alert-link {
+  font-weight: $alert-link-font-weight;
+}
+
+
+// Dismissible alerts
+//
+// Expand the right padding and account for the close button's positioning.
+
+.alert-dismissible {
+  padding-right: ($close-font-size + $alert-padding-x * 2);
+
+  // Adjust close link position
+  .close {
+    position: absolute;
+    top: 0;
+    right: 0;
+    padding: $alert-padding-y $alert-padding-x;
+    color: inherit;
+  }
+}
+
+
+// Alternate styles
+//
+// Generate contextual modifier classes for colorizing the alert.
+
+@each $color, $value in $theme-colors {
+  .alert-#{$color} {
+    @include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), theme-color-level($color, $alert-color-level));
+  }
+}

+ 47 - 47
src/client/styles/bootstrap4/_badge.scss

@@ -1,47 +1,47 @@
-// Base class
-//
-// Requires one of the contextual, color modifier classes for `color` and
-// `background-color`.
-
-.badge {
-  display: inline-block;
-  padding: $badge-padding-y $badge-padding-x;
-  font-size: $badge-font-size;
-  font-weight: $badge-font-weight;
-  line-height: 1;
-  text-align: center;
-  white-space: nowrap;
-  vertical-align: baseline;
-  @include border-radius($badge-border-radius);
-
-  // Empty badges collapse automatically
-  &:empty {
-    display: none;
-  }
-}
-
-// Quick fix for badges in buttons
-.btn .badge {
-  position: relative;
-  top: -1px;
-}
-
-// Pill badges
-//
-// Make them extra rounded with a modifier to replace v3's badges.
-
-.badge-pill {
-  padding-right: $badge-pill-padding-x;
-  padding-left: $badge-pill-padding-x;
-  @include border-radius($badge-pill-border-radius);
-}
-
-// Colors
-//
-// Contextual variations (linked badges get darker on :hover).
-
-@each $color, $value in $theme-colors {
-  .badge-#{$color} {
-    @include badge-variant($value);
-  }
-}
+// Base class
+//
+// Requires one of the contextual, color modifier classes for `color` and
+// `background-color`.
+
+.badge {
+  display: inline-block;
+  padding: $badge-padding-y $badge-padding-x;
+  font-size: $badge-font-size;
+  font-weight: $badge-font-weight;
+  line-height: 1;
+  text-align: center;
+  white-space: nowrap;
+  vertical-align: baseline;
+  @include border-radius($badge-border-radius);
+
+  // Empty badges collapse automatically
+  &:empty {
+    display: none;
+  }
+}
+
+// Quick fix for badges in buttons
+.btn .badge {
+  position: relative;
+  top: -1px;
+}
+
+// Pill badges
+//
+// Make them extra rounded with a modifier to replace v3's badges.
+
+.badge-pill {
+  padding-right: $badge-pill-padding-x;
+  padding-left: $badge-pill-padding-x;
+  @include border-radius($badge-pill-border-radius);
+}
+
+// Colors
+//
+// Contextual variations (linked badges get darker on :hover).
+
+@each $color, $value in $theme-colors {
+  .badge-#{$color} {
+    @include badge-variant($value);
+  }
+}

+ 41 - 38
src/client/styles/bootstrap4/_breadcrumb.scss

@@ -1,38 +1,41 @@
-.breadcrumb {
-  display: flex;
-  flex-wrap: wrap;
-  padding: $breadcrumb-padding-y $breadcrumb-padding-x;
-  margin-bottom: $breadcrumb-margin-bottom;
-  list-style: none;
-  background-color: $breadcrumb-bg;
-  @include border-radius($border-radius);
-}
-
-.breadcrumb-item {
-  // The separator between breadcrumbs (by default, a forward-slash: "/")
-  + .breadcrumb-item::before {
-    display: inline-block; // Suppress underlining of the separator in modern browsers
-    padding-right: $breadcrumb-item-padding;
-    padding-left: $breadcrumb-item-padding;
-    color: $breadcrumb-divider-color;
-    content: "#{$breadcrumb-divider}";
-  }
-
-  // IE9-11 hack to properly handle hyperlink underlines for breadcrumbs built
-  // without `<ul>`s. The `::before` pseudo-element generates an element
-  // *within* the .breadcrumb-item and thereby inherits the `text-decoration`.
-  //
-  // To trick IE into suppressing the underline, we give the pseudo-element an
-  // underline and then immediately remove it.
-  + .breadcrumb-item:hover::before {
-    text-decoration: underline;
-  }
-  // stylelint-disable-next-line no-duplicate-selectors
-  + .breadcrumb-item:hover::before {
-    text-decoration: none;
-  }
-
-  &.active {
-    color: $breadcrumb-active-color;
-  }
-}
+.breadcrumb {
+  display: flex;
+  flex-wrap: wrap;
+  padding: $breadcrumb-padding-y $breadcrumb-padding-x;
+  margin-bottom: $breadcrumb-margin-bottom;
+  list-style: none;
+  background-color: $breadcrumb-bg;
+  @include border-radius($breadcrumb-border-radius);
+}
+
+.breadcrumb-item {
+  // The separator between breadcrumbs (by default, a forward-slash: "/")
+  + .breadcrumb-item {
+    padding-left: $breadcrumb-item-padding;
+
+    &::before {
+      display: inline-block; // Suppress underlining of the separator in modern browsers
+      padding-right: $breadcrumb-item-padding;
+      color: $breadcrumb-divider-color;
+      content: $breadcrumb-divider;
+    }
+  }
+
+  // IE9-11 hack to properly handle hyperlink underlines for breadcrumbs built
+  // without `<ul>`s. The `::before` pseudo-element generates an element
+  // *within* the .breadcrumb-item and thereby inherits the `text-decoration`.
+  //
+  // To trick IE into suppressing the underline, we give the pseudo-element an
+  // underline and then immediately remove it.
+  + .breadcrumb-item:hover::before {
+    text-decoration: underline;
+  }
+  // stylelint-disable-next-line no-duplicate-selectors
+  + .breadcrumb-item:hover::before {
+    text-decoration: none;
+  }
+
+  &.active {
+    color: $breadcrumb-active-color;
+  }
+}

+ 172 - 166
src/client/styles/bootstrap4/_button-group.scss

@@ -1,166 +1,172 @@
-// stylelint-disable selector-no-qualifying-type
-
-// Make the div behave like a button
-.btn-group,
-.btn-group-vertical {
-  position: relative;
-  display: inline-flex;
-  vertical-align: middle; // match .btn alignment given font-size hack above
-
-  > .btn {
-    position: relative;
-    flex: 0 1 auto;
-
-    // Bring the hover, focused, and "active" buttons to the front to overlay
-    // the borders properly
-    @include hover {
-      z-index: 1;
-    }
-    &:focus,
-    &:active,
-    &.active {
-      z-index: 1;
-    }
-  }
-
-  // Prevent double borders when buttons are next to each other
-  .btn + .btn,
-  .btn + .btn-group,
-  .btn-group + .btn,
-  .btn-group + .btn-group {
-    margin-left: -$btn-border-width;
-  }
-}
-
-// Optional: Group multiple button groups together for a toolbar
-.btn-toolbar {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: flex-start;
-
-  .input-group {
-    width: auto;
-  }
-}
-
-.btn-group {
-  > .btn:first-child {
-    margin-left: 0;
-  }
-
-  // Reset rounded corners
-  > .btn:not(:last-child):not(.dropdown-toggle),
-  > .btn-group:not(:last-child) > .btn {
-    @include border-right-radius(0);
-  }
-
-  > .btn:not(:first-child),
-  > .btn-group:not(:first-child) > .btn {
-    @include border-left-radius(0);
-  }
-}
-
-// Sizing
-//
-// Remix the default button sizing classes into new ones for easier manipulation.
-
-.btn-group-sm > .btn { @extend .btn-sm; }
-.btn-group-lg > .btn { @extend .btn-lg; }
-
-
-//
-// Split button dropdowns
-//
-
-.dropdown-toggle-split {
-  padding-right: $btn-padding-x * .75;
-  padding-left: $btn-padding-x * .75;
-
-  &::after {
-    margin-left: 0;
-  }
-}
-
-.btn-sm + .dropdown-toggle-split {
-  padding-right: $btn-padding-x-sm * .75;
-  padding-left: $btn-padding-x-sm * .75;
-}
-
-.btn-lg + .dropdown-toggle-split {
-  padding-right: $btn-padding-x-lg * .75;
-  padding-left: $btn-padding-x-lg * .75;
-}
-
-
-// The clickable button for toggling the menu
-// Set the same inset shadow as the :active state
-.btn-group.show .dropdown-toggle {
-  @include box-shadow($btn-active-box-shadow);
-
-  // Show no shadow for `.btn-link` since it has no other button styles.
-  &.btn-link {
-    @include box-shadow(none);
-  }
-}
-
-
-//
-// Vertical button groups
-//
-
-.btn-group-vertical {
-  flex-direction: column;
-  align-items: flex-start;
-  justify-content: center;
-
-  .btn,
-  .btn-group {
-    width: 100%;
-  }
-
-  > .btn + .btn,
-  > .btn + .btn-group,
-  > .btn-group + .btn,
-  > .btn-group + .btn-group {
-    margin-top: -$btn-border-width;
-    margin-left: 0;
-  }
-
-  // Reset rounded corners
-  > .btn:not(:last-child):not(.dropdown-toggle),
-  > .btn-group:not(:last-child) > .btn {
-    @include border-bottom-radius(0);
-  }
-
-  > .btn:not(:first-child),
-  > .btn-group:not(:first-child) > .btn {
-    @include border-top-radius(0);
-  }
-}
-
-
-// Checkbox and radio options
-//
-// In order to support the browser's form validation feedback, powered by the
-// `required` attribute, we have to "hide" the inputs via `clip`. We cannot use
-// `display: none;` or `visibility: hidden;` as that also hides the popover.
-// Simply visually hiding the inputs via `opacity` would leave them clickable in
-// certain cases which is prevented by using `clip` and `pointer-events`.
-// This way, we ensure a DOM element is visible to position the popover from.
-//
-// See https://github.com/twbs/bootstrap/pull/12794 and
-// https://github.com/twbs/bootstrap/pull/14559 for more information.
-
-.btn-group-toggle {
-  > .btn,
-  > .btn-group > .btn {
-    margin-bottom: 0; // Override default `<label>` value
-
-    input[type="radio"],
-    input[type="checkbox"] {
-      position: absolute;
-      clip: rect(0, 0, 0, 0);
-      pointer-events: none;
-    }
-  }
-}
+// stylelint-disable selector-no-qualifying-type
+
+// Make the div behave like a button
+.btn-group,
+.btn-group-vertical {
+  position: relative;
+  display: inline-flex;
+  vertical-align: middle; // match .btn alignment given font-size hack above
+
+  > .btn {
+    position: relative;
+    flex: 0 1 auto;
+
+    // Bring the hover, focused, and "active" buttons to the front to overlay
+    // the borders properly
+    @include hover {
+      z-index: 1;
+    }
+    &:focus,
+    &:active,
+    &.active {
+      z-index: 1;
+    }
+  }
+
+  // Prevent double borders when buttons are next to each other
+  .btn + .btn,
+  .btn + .btn-group,
+  .btn-group + .btn,
+  .btn-group + .btn-group {
+    margin-left: -$btn-border-width;
+  }
+}
+
+// Optional: Group multiple button groups together for a toolbar
+.btn-toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-start;
+
+  .input-group {
+    width: auto;
+  }
+}
+
+.btn-group {
+  > .btn:first-child {
+    margin-left: 0;
+  }
+
+  // Reset rounded corners
+  > .btn:not(:last-child):not(.dropdown-toggle),
+  > .btn-group:not(:last-child) > .btn {
+    @include border-right-radius(0);
+  }
+
+  > .btn:not(:first-child),
+  > .btn-group:not(:first-child) > .btn {
+    @include border-left-radius(0);
+  }
+}
+
+// Sizing
+//
+// Remix the default button sizing classes into new ones for easier manipulation.
+
+.btn-group-sm > .btn { @extend .btn-sm; }
+.btn-group-lg > .btn { @extend .btn-lg; }
+
+
+//
+// Split button dropdowns
+//
+
+.dropdown-toggle-split {
+  padding-right: $btn-padding-x * .75;
+  padding-left: $btn-padding-x * .75;
+
+  &::after,
+  .dropup &::after,
+  .dropright &::after {
+    margin-left: 0;
+  }
+
+  .dropleft &::before {
+    margin-right: 0;
+  }
+}
+
+.btn-sm + .dropdown-toggle-split {
+  padding-right: $btn-padding-x-sm * .75;
+  padding-left: $btn-padding-x-sm * .75;
+}
+
+.btn-lg + .dropdown-toggle-split {
+  padding-right: $btn-padding-x-lg * .75;
+  padding-left: $btn-padding-x-lg * .75;
+}
+
+
+// The clickable button for toggling the menu
+// Set the same inset shadow as the :active state
+.btn-group.show .dropdown-toggle {
+  @include box-shadow($btn-active-box-shadow);
+
+  // Show no shadow for `.btn-link` since it has no other button styles.
+  &.btn-link {
+    @include box-shadow(none);
+  }
+}
+
+
+//
+// Vertical button groups
+//
+
+.btn-group-vertical {
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+
+  .btn,
+  .btn-group {
+    width: 100%;
+  }
+
+  > .btn + .btn,
+  > .btn + .btn-group,
+  > .btn-group + .btn,
+  > .btn-group + .btn-group {
+    margin-top: -$btn-border-width;
+    margin-left: 0;
+  }
+
+  // Reset rounded corners
+  > .btn:not(:last-child):not(.dropdown-toggle),
+  > .btn-group:not(:last-child) > .btn {
+    @include border-bottom-radius(0);
+  }
+
+  > .btn:not(:first-child),
+  > .btn-group:not(:first-child) > .btn {
+    @include border-top-radius(0);
+  }
+}
+
+
+// Checkbox and radio options
+//
+// In order to support the browser's form validation feedback, powered by the
+// `required` attribute, we have to "hide" the inputs via `clip`. We cannot use
+// `display: none;` or `visibility: hidden;` as that also hides the popover.
+// Simply visually hiding the inputs via `opacity` would leave them clickable in
+// certain cases which is prevented by using `clip` and `pointer-events`.
+// This way, we ensure a DOM element is visible to position the popover from.
+//
+// See https://github.com/twbs/bootstrap/pull/12794 and
+// https://github.com/twbs/bootstrap/pull/14559 for more information.
+
+.btn-group-toggle {
+  > .btn,
+  > .btn-group > .btn {
+    margin-bottom: 0; // Override default `<label>` value
+
+    input[type="radio"],
+    input[type="checkbox"] {
+      position: absolute;
+      clip: rect(0, 0, 0, 0);
+      pointer-events: none;
+    }
+  }
+}

+ 143 - 143
src/client/styles/bootstrap4/_buttons.scss

@@ -1,143 +1,143 @@
-// stylelint-disable selector-no-qualifying-type
-
-//
-// Base styles
-//
-
-.btn {
-  display: inline-block;
-  font-weight: $btn-font-weight;
-  text-align: center;
-  white-space: nowrap;
-  vertical-align: middle;
-  user-select: none;
-  border: $btn-border-width solid transparent;
-  @include button-size($btn-padding-y, $btn-padding-x, $font-size-base, $btn-line-height, $btn-border-radius);
-  @include transition($btn-transition);
-
-  // Share hover and focus styles
-  @include hover-focus {
-    text-decoration: none;
-  }
-
-  &:focus,
-  &.focus {
-    outline: 0;
-    box-shadow: $btn-focus-box-shadow;
-  }
-
-  // Disabled comes first so active can properly restyle
-  &.disabled,
-  &:disabled {
-    opacity: $btn-disabled-opacity;
-    @include box-shadow(none);
-  }
-
-  // Opinionated: add "hand" cursor to non-disabled .btn elements
-  &:not(:disabled):not(.disabled) {
-    cursor: pointer;
-  }
-
-  &:not(:disabled):not(.disabled):active,
-  &:not(:disabled):not(.disabled).active {
-    background-image: none;
-    @include box-shadow($btn-active-box-shadow);
-
-    &:focus {
-      @include box-shadow($btn-focus-box-shadow, $btn-active-box-shadow);
-    }
-  }
-}
-
-// Future-proof disabling of clicks on `<a>` elements
-a.btn.disabled,
-fieldset:disabled a.btn {
-  pointer-events: none;
-}
-
-
-//
-// Alternate buttons
-//
-
-@each $color, $value in $theme-colors {
-  .btn-#{$color} {
-    @include button-variant($value, $value);
-  }
-}
-
-@each $color, $value in $theme-colors {
-  .btn-outline-#{$color} {
-    @include button-outline-variant($value);
-  }
-}
-
-
-//
-// Link buttons
-//
-
-// Make a button look and behave like a link
-.btn-link {
-  font-weight: $font-weight-normal;
-  color: $link-color;
-  background-color: transparent;
-
-  @include hover {
-    color: $link-hover-color;
-    text-decoration: $link-hover-decoration;
-    background-color: transparent;
-    border-color: transparent;
-  }
-
-  &:focus,
-  &.focus {
-    text-decoration: $link-hover-decoration;
-    border-color: transparent;
-    box-shadow: none;
-  }
-
-  &:disabled,
-  &.disabled {
-    color: $btn-link-disabled-color;
-  }
-
-  // No need for an active state here
-}
-
-
-//
-// Button Sizes
-//
-
-.btn-lg {
-  @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $font-size-lg, $btn-line-height-lg, $btn-border-radius-lg);
-}
-
-.btn-sm {
-  @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $btn-line-height-sm, $btn-border-radius-sm);
-}
-
-
-//
-// Block button
-//
-
-.btn-block {
-  display: block;
-  width: 100%;
-
-  // Vertically space out multiple block buttons
-  + .btn-block {
-    margin-top: $btn-block-spacing-y;
-  }
-}
-
-// Specificity overrides
-input[type="submit"],
-input[type="reset"],
-input[type="button"] {
-  &.btn-block {
-    width: 100%;
-  }
-}
+// stylelint-disable selector-no-qualifying-type
+
+//
+// Base styles
+//
+
+.btn {
+  display: inline-block;
+  font-weight: $btn-font-weight;
+  text-align: center;
+  white-space: nowrap;
+  vertical-align: middle;
+  user-select: none;
+  border: $btn-border-width solid transparent;
+  @include button-size($btn-padding-y, $btn-padding-x, $font-size-base, $btn-line-height, $btn-border-radius);
+  @include transition($btn-transition);
+
+  // Share hover and focus styles
+  @include hover-focus {
+    text-decoration: none;
+  }
+
+  &:focus,
+  &.focus {
+    outline: 0;
+    box-shadow: $btn-focus-box-shadow;
+  }
+
+  // Disabled comes first so active can properly restyle
+  &.disabled,
+  &:disabled {
+    opacity: $btn-disabled-opacity;
+    @include box-shadow(none);
+  }
+
+  // Opinionated: add "hand" cursor to non-disabled .btn elements
+  &:not(:disabled):not(.disabled) {
+    cursor: pointer;
+  }
+
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    @include box-shadow($btn-active-box-shadow);
+
+    &:focus {
+      @include box-shadow($btn-focus-box-shadow, $btn-active-box-shadow);
+    }
+  }
+}
+
+// Future-proof disabling of clicks on `<a>` elements
+a.btn.disabled,
+fieldset:disabled a.btn {
+  pointer-events: none;
+}
+
+
+//
+// Alternate buttons
+//
+
+@each $color, $value in $theme-colors {
+  .btn-#{$color} {
+    @include button-variant($value, $value);
+  }
+}
+
+@each $color, $value in $theme-colors {
+  .btn-outline-#{$color} {
+    @include button-outline-variant($value);
+  }
+}
+
+
+//
+// Link buttons
+//
+
+// Make a button look and behave like a link
+.btn-link {
+  font-weight: $font-weight-normal;
+  color: $link-color;
+  background-color: transparent;
+
+  @include hover {
+    color: $link-hover-color;
+    text-decoration: $link-hover-decoration;
+    background-color: transparent;
+    border-color: transparent;
+  }
+
+  &:focus,
+  &.focus {
+    text-decoration: $link-hover-decoration;
+    border-color: transparent;
+    box-shadow: none;
+  }
+
+  &:disabled,
+  &.disabled {
+    color: $btn-link-disabled-color;
+    pointer-events: none;
+  }
+
+  // No need for an active state here
+}
+
+
+//
+// Button Sizes
+//
+
+.btn-lg {
+  @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $font-size-lg, $btn-line-height-lg, $btn-border-radius-lg);
+}
+
+.btn-sm {
+  @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $btn-line-height-sm, $btn-border-radius-sm);
+}
+
+
+//
+// Block button
+//
+
+.btn-block {
+  display: block;
+  width: 100%;
+
+  // Vertically space out multiple block buttons
+  + .btn-block {
+    margin-top: $btn-block-spacing-y;
+  }
+}
+
+// Specificity overrides
+input[type="submit"],
+input[type="reset"],
+input[type="button"] {
+  &.btn-block {
+    width: 100%;
+  }
+}

+ 301 - 270
src/client/styles/bootstrap4/_card.scss

@@ -1,270 +1,301 @@
-//
-// Base styles
-//
-
-.card {
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  min-width: 0;
-  word-wrap: break-word;
-  background-color: $card-bg;
-  background-clip: border-box;
-  border: $card-border-width solid $card-border-color;
-  @include border-radius($card-border-radius);
-
-  > hr {
-    margin-right: 0;
-    margin-left: 0;
-  }
-
-  > .list-group:first-child {
-    .list-group-item:first-child {
-      @include border-top-radius($card-border-radius);
-    }
-  }
-
-  > .list-group:last-child {
-    .list-group-item:last-child {
-      @include border-bottom-radius($card-border-radius);
-    }
-  }
-}
-
-.card-body {
-  // Enable `flex-grow: 1` for decks and groups so that card blocks take up
-  // as much space as possible, ensuring footers are aligned to the bottom.
-  flex: 1 1 auto;
-  padding: $card-spacer-x;
-}
-
-.card-title {
-  margin-bottom: $card-spacer-y;
-}
-
-.card-subtitle {
-  margin-top: -($card-spacer-y / 2);
-  margin-bottom: 0;
-}
-
-.card-text:last-child {
-  margin-bottom: 0;
-}
-
-.card-link {
-  @include hover {
-    text-decoration: none;
-  }
-
-  + .card-link {
-    margin-left: $card-spacer-x;
-  }
-}
-
-//
-// Optional textual caps
-//
-
-.card-header {
-  padding: $card-spacer-y $card-spacer-x;
-  margin-bottom: 0; // Removes the default margin-bottom of <hN>
-  background-color: $card-cap-bg;
-  border-bottom: $card-border-width solid $card-border-color;
-
-  &:first-child {
-    @include border-radius($card-inner-border-radius $card-inner-border-radius 0 0);
-  }
-
-  + .list-group {
-    .list-group-item:first-child {
-      border-top: 0;
-    }
-  }
-}
-
-.card-footer {
-  padding: $card-spacer-y $card-spacer-x;
-  background-color: $card-cap-bg;
-  border-top: $card-border-width solid $card-border-color;
-
-  &:last-child {
-    @include border-radius(0 0 $card-inner-border-radius $card-inner-border-radius);
-  }
-}
-
-
-//
-// Header navs
-//
-
-.card-header-tabs {
-  margin-right: -($card-spacer-x / 2);
-  margin-bottom: -$card-spacer-y;
-  margin-left: -($card-spacer-x / 2);
-  border-bottom: 0;
-}
-
-.card-header-pills {
-  margin-right: -($card-spacer-x / 2);
-  margin-left: -($card-spacer-x / 2);
-}
-
-// Card image
-.card-img-overlay {
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  padding: $card-img-overlay-padding;
-}
-
-.card-img {
-  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
-  @include border-radius($card-inner-border-radius);
-}
-
-// Card image caps
-.card-img-top {
-  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
-  @include border-top-radius($card-inner-border-radius);
-}
-
-.card-img-bottom {
-  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
-  @include border-bottom-radius($card-inner-border-radius);
-}
-
-
-// Card deck
-
-.card-deck {
-  display: flex;
-  flex-direction: column;
-
-  .card {
-    margin-bottom: $card-deck-margin;
-  }
-
-  @include media-breakpoint-up(sm) {
-    flex-flow: row wrap;
-    margin-right: -$card-deck-margin;
-    margin-left: -$card-deck-margin;
-
-    .card {
-      display: flex;
-      // Flexbugs #4: https://github.com/philipwalton/flexbugs#4-flex-shorthand-declarations-with-unitless-flex-basis-values-are-ignored
-      flex: 1 0 0%;
-      flex-direction: column;
-      margin-right: $card-deck-margin;
-      margin-bottom: 0; // Override the default
-      margin-left: $card-deck-margin;
-    }
-  }
-}
-
-
-//
-// Card groups
-//
-
-.card-group {
-  display: flex;
-  flex-direction: column;
-
-  // The child selector allows nested `.card` within `.card-group`
-  // to display properly.
-  > .card {
-    margin-bottom: $card-group-margin;
-  }
-
-  @include media-breakpoint-up(sm) {
-    flex-flow: row wrap;
-    // The child selector allows nested `.card` within `.card-group`
-    // to display properly.
-    > .card {
-      // Flexbugs #4: https://github.com/philipwalton/flexbugs#4-flex-shorthand-declarations-with-unitless-flex-basis-values-are-ignored
-      flex: 1 0 0%;
-      margin-bottom: 0;
-
-      + .card {
-        margin-left: 0;
-        border-left: 0;
-      }
-
-      // Handle rounded corners
-      @if $enable-rounded {
-        &:first-child {
-          @include border-right-radius(0);
-
-          .card-img-top,
-          .card-header {
-            border-top-right-radius: 0;
-          }
-          .card-img-bottom,
-          .card-footer {
-            border-bottom-right-radius: 0;
-          }
-        }
-
-        &:last-child {
-          @include border-left-radius(0);
-
-          .card-img-top,
-          .card-header {
-            border-top-left-radius: 0;
-          }
-          .card-img-bottom,
-          .card-footer {
-            border-bottom-left-radius: 0;
-          }
-        }
-
-        &:only-child {
-          @include border-radius($card-border-radius);
-
-          .card-img-top,
-          .card-header {
-            @include border-top-radius($card-border-radius);
-          }
-          .card-img-bottom,
-          .card-footer {
-            @include border-bottom-radius($card-border-radius);
-          }
-        }
-
-        &:not(:first-child):not(:last-child):not(:only-child) {
-          @include border-radius(0);
-
-          .card-img-top,
-          .card-img-bottom,
-          .card-header,
-          .card-footer {
-            @include border-radius(0);
-          }
-        }
-      }
-    }
-  }
-}
-
-
-//
-// Columns
-//
-
-.card-columns {
-  .card {
-    margin-bottom: $card-columns-margin;
-  }
-
-  @include media-breakpoint-up(sm) {
-    column-count: $card-columns-count;
-    column-gap: $card-columns-gap;
-
-    .card {
-      display: inline-block; // Don't let them vertically span multiple columns
-      width: 100%; // Don't let their width change
-    }
-  }
-}
+//
+// Base styles
+//
+
+.card {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  word-wrap: break-word;
+  background-color: $card-bg;
+  background-clip: border-box;
+  border: $card-border-width solid $card-border-color;
+  @include border-radius($card-border-radius);
+
+  > hr {
+    margin-right: 0;
+    margin-left: 0;
+  }
+
+  > .list-group:first-child {
+    .list-group-item:first-child {
+      @include border-top-radius($card-border-radius);
+    }
+  }
+
+  > .list-group:last-child {
+    .list-group-item:last-child {
+      @include border-bottom-radius($card-border-radius);
+    }
+  }
+}
+
+.card-body {
+  // Enable `flex-grow: 1` for decks and groups so that card blocks take up
+  // as much space as possible, ensuring footers are aligned to the bottom.
+  flex: 1 1 auto;
+  padding: $card-spacer-x;
+}
+
+.card-title {
+  margin-bottom: $card-spacer-y;
+}
+
+.card-subtitle {
+  margin-top: -($card-spacer-y / 2);
+  margin-bottom: 0;
+}
+
+.card-text:last-child {
+  margin-bottom: 0;
+}
+
+.card-link {
+  @include hover {
+    text-decoration: none;
+  }
+
+  + .card-link {
+    margin-left: $card-spacer-x;
+  }
+}
+
+//
+// Optional textual caps
+//
+
+.card-header {
+  padding: $card-spacer-y $card-spacer-x;
+  margin-bottom: 0; // Removes the default margin-bottom of <hN>
+  background-color: $card-cap-bg;
+  border-bottom: $card-border-width solid $card-border-color;
+
+  &:first-child {
+    @include border-radius($card-inner-border-radius $card-inner-border-radius 0 0);
+  }
+
+  + .list-group {
+    .list-group-item:first-child {
+      border-top: 0;
+    }
+  }
+}
+
+.card-footer {
+  padding: $card-spacer-y $card-spacer-x;
+  background-color: $card-cap-bg;
+  border-top: $card-border-width solid $card-border-color;
+
+  &:last-child {
+    @include border-radius(0 0 $card-inner-border-radius $card-inner-border-radius);
+  }
+}
+
+
+//
+// Header navs
+//
+
+.card-header-tabs {
+  margin-right: -($card-spacer-x / 2);
+  margin-bottom: -$card-spacer-y;
+  margin-left: -($card-spacer-x / 2);
+  border-bottom: 0;
+}
+
+.card-header-pills {
+  margin-right: -($card-spacer-x / 2);
+  margin-left: -($card-spacer-x / 2);
+}
+
+// Card image
+.card-img-overlay {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: $card-img-overlay-padding;
+}
+
+.card-img {
+  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
+  @include border-radius($card-inner-border-radius);
+}
+
+// Card image caps
+.card-img-top {
+  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
+  @include border-top-radius($card-inner-border-radius);
+}
+
+.card-img-bottom {
+  width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
+  @include border-bottom-radius($card-inner-border-radius);
+}
+
+
+// Card deck
+
+.card-deck {
+  display: flex;
+  flex-direction: column;
+
+  .card {
+    margin-bottom: $card-deck-margin;
+  }
+
+  @include media-breakpoint-up(sm) {
+    flex-flow: row wrap;
+    margin-right: -$card-deck-margin;
+    margin-left: -$card-deck-margin;
+
+    .card {
+      display: flex;
+      // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4
+      flex: 1 0 0%;
+      flex-direction: column;
+      margin-right: $card-deck-margin;
+      margin-bottom: 0; // Override the default
+      margin-left: $card-deck-margin;
+    }
+  }
+}
+
+
+//
+// Card groups
+//
+
+.card-group {
+  display: flex;
+  flex-direction: column;
+
+  // The child selector allows nested `.card` within `.card-group`
+  // to display properly.
+  > .card {
+    margin-bottom: $card-group-margin;
+  }
+
+  @include media-breakpoint-up(sm) {
+    flex-flow: row wrap;
+    // The child selector allows nested `.card` within `.card-group`
+    // to display properly.
+    > .card {
+      // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4
+      flex: 1 0 0%;
+      margin-bottom: 0;
+
+      + .card {
+        margin-left: 0;
+        border-left: 0;
+      }
+
+      // Handle rounded corners
+      @if $enable-rounded {
+        &:first-child {
+          @include border-right-radius(0);
+
+          .card-img-top,
+          .card-header {
+            border-top-right-radius: 0;
+          }
+          .card-img-bottom,
+          .card-footer {
+            border-bottom-right-radius: 0;
+          }
+        }
+
+        &:last-child {
+          @include border-left-radius(0);
+
+          .card-img-top,
+          .card-header {
+            border-top-left-radius: 0;
+          }
+          .card-img-bottom,
+          .card-footer {
+            border-bottom-left-radius: 0;
+          }
+        }
+
+        &:only-child {
+          @include border-radius($card-border-radius);
+
+          .card-img-top,
+          .card-header {
+            @include border-top-radius($card-border-radius);
+          }
+          .card-img-bottom,
+          .card-footer {
+            @include border-bottom-radius($card-border-radius);
+          }
+        }
+
+        &:not(:first-child):not(:last-child):not(:only-child) {
+          @include border-radius(0);
+
+          .card-img-top,
+          .card-img-bottom,
+          .card-header,
+          .card-footer {
+            @include border-radius(0);
+          }
+        }
+      }
+    }
+  }
+}
+
+
+//
+// Columns
+//
+
+.card-columns {
+  .card {
+    margin-bottom: $card-columns-margin;
+  }
+
+  @include media-breakpoint-up(sm) {
+    column-count: $card-columns-count;
+    column-gap: $card-columns-gap;
+    orphans: 1;
+    widows: 1;
+
+    .card {
+      display: inline-block; // Don't let them vertically span multiple columns
+      width: 100%; // Don't let their width change
+    }
+  }
+}
+
+
+//
+// Accordion
+//
+
+.accordion {
+  .card:not(:first-of-type):not(:last-of-type) {
+    border-bottom: 0;
+    border-radius: 0;
+  }
+
+  .card:not(:first-of-type) {
+    .card-header:first-child {
+      border-radius: 0;
+    }
+  }
+
+  .card:first-of-type {
+    border-bottom: 0;
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+  }
+
+  .card:last-of-type {
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
+  }
+}

+ 236 - 191
src/client/styles/bootstrap4/_carousel.scss

@@ -1,191 +1,236 @@
-// Wrapper for the slide container and indicators
-.carousel {
-  position: relative;
-}
-
-.carousel-inner {
-  position: relative;
-  width: 100%;
-  overflow: hidden;
-}
-
-.carousel-item {
-  position: relative;
-  display: none;
-  align-items: center;
-  width: 100%;
-  @include transition($carousel-transition);
-  backface-visibility: hidden;
-  perspective: 1000px;
-}
-
-.carousel-item.active,
-.carousel-item-next,
-.carousel-item-prev {
-  display: block;
-}
-
-.carousel-item-next,
-.carousel-item-prev {
-  position: absolute;
-  top: 0;
-}
-
-// CSS3 transforms when supported by the browser
-.carousel-item-next.carousel-item-left,
-.carousel-item-prev.carousel-item-right {
-  transform: translateX(0);
-
-  @supports (transform-style: preserve-3d) {
-    transform: translate3d(0, 0, 0);
-  }
-}
-
-.carousel-item-next,
-.active.carousel-item-right {
-  transform: translateX(100%);
-
-  @supports (transform-style: preserve-3d) {
-    transform: translate3d(100%, 0, 0);
-  }
-}
-
-.carousel-item-prev,
-.active.carousel-item-left {
-  transform: translateX(-100%);
-
-  @supports (transform-style: preserve-3d) {
-    transform: translate3d(-100%, 0, 0);
-  }
-}
-
-
-//
-// Left/right controls for nav
-//
-
-.carousel-control-prev,
-.carousel-control-next {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  // Use flex for alignment (1-3)
-  display: flex; // 1. allow flex styles
-  align-items: center; // 2. vertically center contents
-  justify-content: center; // 3. horizontally center contents
-  width: $carousel-control-width;
-  color: $carousel-control-color;
-  text-align: center;
-  opacity: $carousel-control-opacity;
-  // We can't have a transition here because WebKit cancels the carousel
-  // animation if you trip this while in the middle of another animation.
-
-  // Hover/focus state
-  @include hover-focus {
-    color: $carousel-control-color;
-    text-decoration: none;
-    outline: 0;
-    opacity: .9;
-  }
-}
-.carousel-control-prev {
-  left: 0;
-  @if $enable-gradients {
-    background: linear-gradient(90deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .001));
-  }
-}
-.carousel-control-next {
-  right: 0;
-  @if $enable-gradients {
-    background: linear-gradient(270deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .001));
-  }
-}
-
-// Icons for within
-.carousel-control-prev-icon,
-.carousel-control-next-icon {
-  display: inline-block;
-  width: $carousel-control-icon-width;
-  height: $carousel-control-icon-width;
-  background: transparent no-repeat center center;
-  background-size: 100% 100%;
-}
-.carousel-control-prev-icon {
-  background-image: $carousel-control-prev-icon-bg;
-}
-.carousel-control-next-icon {
-  background-image: $carousel-control-next-icon-bg;
-}
-
-
-// Optional indicator pips
-//
-// Add an ordered list with the following class and add a list item for each
-// slide your carousel holds.
-
-.carousel-indicators {
-  position: absolute;
-  right: 0;
-  bottom: 10px;
-  left: 0;
-  z-index: 15;
-  display: flex;
-  justify-content: center;
-  padding-left: 0; // override <ol> default
-  // Use the .carousel-control's width as margin so we don't overlay those
-  margin-right: $carousel-control-width;
-  margin-left: $carousel-control-width;
-  list-style: none;
-
-  li {
-    position: relative;
-    flex: 0 1 auto;
-    width: $carousel-indicator-width;
-    height: $carousel-indicator-height;
-    margin-right: $carousel-indicator-spacer;
-    margin-left: $carousel-indicator-spacer;
-    text-indent: -999px;
-    background-color: rgba($carousel-indicator-active-bg, .5);
-
-    // Use pseudo classes to increase the hit area by 10px on top and bottom.
-    &::before {
-      position: absolute;
-      top: -10px;
-      left: 0;
-      display: inline-block;
-      width: 100%;
-      height: 10px;
-      content: "";
-    }
-    &::after {
-      position: absolute;
-      bottom: -10px;
-      left: 0;
-      display: inline-block;
-      width: 100%;
-      height: 10px;
-      content: "";
-    }
-  }
-
-  .active {
-    background-color: $carousel-indicator-active-bg;
-  }
-}
-
-
-// Optional captions
-//
-//
-
-.carousel-caption {
-  position: absolute;
-  right: ((100% - $carousel-caption-width) / 2);
-  bottom: 20px;
-  left: ((100% - $carousel-caption-width) / 2);
-  z-index: 10;
-  padding-top: 20px;
-  padding-bottom: 20px;
-  color: $carousel-caption-color;
-  text-align: center;
-}
+// Notes on the classes:
+//
+// 1. The .carousel-item-left and .carousel-item-right is used to indicate where
+//    the active slide is heading.
+// 2. .active.carousel-item is the current slide.
+// 3. .active.carousel-item-left and .active.carousel-item-right is the current
+//    slide in its in-transition state. Only one of these occurs at a time.
+// 4. .carousel-item-next.carousel-item-left and .carousel-item-prev.carousel-item-right
+//    is the upcoming slide in transition.
+
+.carousel {
+  position: relative;
+}
+
+.carousel-inner {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+}
+
+.carousel-item {
+  position: relative;
+  display: none;
+  align-items: center;
+  width: 100%;
+  backface-visibility: hidden;
+  perspective: 1000px;
+}
+
+.carousel-item.active,
+.carousel-item-next,
+.carousel-item-prev {
+  display: block;
+  @include transition($carousel-transition);
+}
+
+.carousel-item-next,
+.carousel-item-prev {
+  position: absolute;
+  top: 0;
+}
+
+.carousel-item-next.carousel-item-left,
+.carousel-item-prev.carousel-item-right {
+  transform: translateX(0);
+
+  @supports (transform-style: preserve-3d) {
+    transform: translate3d(0, 0, 0);
+  }
+}
+
+.carousel-item-next,
+.active.carousel-item-right {
+  transform: translateX(100%);
+
+  @supports (transform-style: preserve-3d) {
+    transform: translate3d(100%, 0, 0);
+  }
+}
+
+.carousel-item-prev,
+.active.carousel-item-left {
+  transform: translateX(-100%);
+
+  @supports (transform-style: preserve-3d) {
+    transform: translate3d(-100%, 0, 0);
+  }
+}
+
+
+//
+// Alternate transitions
+//
+
+.carousel-fade {
+  .carousel-item {
+    opacity: 0;
+    transition-duration: .6s;
+    transition-property: opacity;
+  }
+
+  .carousel-item.active,
+  .carousel-item-next.carousel-item-left,
+  .carousel-item-prev.carousel-item-right {
+    opacity: 1;
+  }
+
+  .active.carousel-item-left,
+  .active.carousel-item-right {
+    opacity: 0;
+  }
+
+  .carousel-item-next,
+  .carousel-item-prev,
+  .carousel-item.active,
+  .active.carousel-item-left,
+  .active.carousel-item-prev {
+    transform: translateX(0);
+
+    @supports (transform-style: preserve-3d) {
+      transform: translate3d(0, 0, 0);
+    }
+  }
+}
+
+
+//
+// Left/right controls for nav
+//
+
+.carousel-control-prev,
+.carousel-control-next {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  // Use flex for alignment (1-3)
+  display: flex; // 1. allow flex styles
+  align-items: center; // 2. vertically center contents
+  justify-content: center; // 3. horizontally center contents
+  width: $carousel-control-width;
+  color: $carousel-control-color;
+  text-align: center;
+  opacity: $carousel-control-opacity;
+  // We can't have a transition here because WebKit cancels the carousel
+  // animation if you trip this while in the middle of another animation.
+
+  // Hover/focus state
+  @include hover-focus {
+    color: $carousel-control-color;
+    text-decoration: none;
+    outline: 0;
+    opacity: .9;
+  }
+}
+.carousel-control-prev {
+  left: 0;
+  @if $enable-gradients {
+    background: linear-gradient(90deg, rgba($black, .25), rgba($black, .001));
+  }
+}
+.carousel-control-next {
+  right: 0;
+  @if $enable-gradients {
+    background: linear-gradient(270deg, rgba($black, .25), rgba($black, .001));
+  }
+}
+
+// Icons for within
+.carousel-control-prev-icon,
+.carousel-control-next-icon {
+  display: inline-block;
+  width: $carousel-control-icon-width;
+  height: $carousel-control-icon-width;
+  background: transparent no-repeat center center;
+  background-size: 100% 100%;
+}
+.carousel-control-prev-icon {
+  background-image: $carousel-control-prev-icon-bg;
+}
+.carousel-control-next-icon {
+  background-image: $carousel-control-next-icon-bg;
+}
+
+
+// Optional indicator pips
+//
+// Add an ordered list with the following class and add a list item for each
+// slide your carousel holds.
+
+.carousel-indicators {
+  position: absolute;
+  right: 0;
+  bottom: 10px;
+  left: 0;
+  z-index: 15;
+  display: flex;
+  justify-content: center;
+  padding-left: 0; // override <ol> default
+  // Use the .carousel-control's width as margin so we don't overlay those
+  margin-right: $carousel-control-width;
+  margin-left: $carousel-control-width;
+  list-style: none;
+
+  li {
+    position: relative;
+    flex: 0 1 auto;
+    width: $carousel-indicator-width;
+    height: $carousel-indicator-height;
+    margin-right: $carousel-indicator-spacer;
+    margin-left: $carousel-indicator-spacer;
+    text-indent: -999px;
+    cursor: pointer;
+    background-color: rgba($carousel-indicator-active-bg, .5);
+
+    // Use pseudo classes to increase the hit area by 10px on top and bottom.
+    &::before {
+      position: absolute;
+      top: -10px;
+      left: 0;
+      display: inline-block;
+      width: 100%;
+      height: 10px;
+      content: "";
+    }
+    &::after {
+      position: absolute;
+      bottom: -10px;
+      left: 0;
+      display: inline-block;
+      width: 100%;
+      height: 10px;
+      content: "";
+    }
+  }
+
+  .active {
+    background-color: $carousel-indicator-active-bg;
+  }
+}
+
+
+// Optional captions
+//
+//
+
+.carousel-caption {
+  position: absolute;
+  right: ((100% - $carousel-caption-width) / 2);
+  bottom: 20px;
+  left: ((100% - $carousel-caption-width) / 2);
+  z-index: 10;
+  padding-top: 20px;
+  padding-bottom: 20px;
+  color: $carousel-caption-color;
+  text-align: center;
+}

+ 35 - 34
src/client/styles/bootstrap4/_close.scss

@@ -1,34 +1,35 @@
-.close {
-  float: right;
-  font-size: $close-font-size;
-  font-weight: $close-font-weight;
-  line-height: 1;
-  color: $close-color;
-  text-shadow: $close-text-shadow;
-  opacity: .5;
-
-  @include hover-focus {
-    color: $close-color;
-    text-decoration: none;
-    opacity: .75;
-  }
-
-  // Opinionated: add "hand" cursor to non-disabled .close elements
-  &:not(:disabled):not(.disabled) {
-    cursor: pointer;
-  }
-}
-
-// Additional properties for button version
-// iOS requires the button element instead of an anchor tag.
-// If you want the anchor version, it requires `href="#"`.
-// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
-
-// stylelint-disable property-no-vendor-prefix, selector-no-qualifying-type
-button.close {
-  padding: 0;
-  background-color: transparent;
-  border: 0;
-  -webkit-appearance: none;
-}
-// stylelint-enable
+.close {
+  float: right;
+  font-size: $close-font-size;
+  font-weight: $close-font-weight;
+  line-height: 1;
+  color: $close-color;
+  text-shadow: $close-text-shadow;
+  opacity: .5;
+
+  &:not(:disabled):not(.disabled) {
+
+    @include hover-focus {
+      color: $close-color;
+      text-decoration: none;
+      opacity: .75;
+    }
+
+    // Opinionated: add "hand" cursor to non-disabled .close elements
+    cursor: pointer;
+  }
+}
+
+// Additional properties for button version
+// iOS requires the button element instead of an anchor tag.
+// If you want the anchor version, it requires `href="#"`.
+// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
+
+// stylelint-disable property-no-vendor-prefix, selector-no-qualifying-type
+button.close {
+  padding: 0;
+  background-color: transparent;
+  border: 0;
+  -webkit-appearance: none;
+}
+// stylelint-enable

+ 48 - 56
src/client/styles/bootstrap4/_code.scss

@@ -1,56 +1,48 @@
-// Inline and block code styles
-code,
-kbd,
-pre,
-samp {
-  font-family: $font-family-monospace;
-}
-
-// Inline code
-code {
-  font-size: $code-font-size;
-  color: $code-color;
-  word-break: break-word;
-
-  // Streamline the style when inside anchors to avoid broken underline and more
-  a > & {
-    color: inherit;
-  }
-}
-
-// User input typically entered via keyboard
-kbd {
-  padding: $kbd-padding-y $kbd-padding-x;
-  font-size: $kbd-font-size;
-  color: $kbd-color;
-  background-color: $kbd-bg;
-  @include border-radius($border-radius-sm);
-  @include box-shadow($kbd-box-shadow);
-
-  kbd {
-    padding: 0;
-    font-size: 100%;
-    font-weight: $nested-kbd-font-weight;
-    @include box-shadow(none);
-  }
-}
-
-// Blocks of code
-pre {
-  display: block;
-  font-size: $code-font-size;
-  color: $pre-color;
-
-  // Account for some code outputs that place code tags in pre tags
-  code {
-    font-size: inherit;
-    color: inherit;
-    word-break: normal;
-  }
-}
-
-// Enable scrollable blocks of code
-.pre-scrollable {
-  max-height: $pre-scrollable-max-height;
-  overflow-y: scroll;
-}
+// Inline code
+code {
+  font-size: $code-font-size;
+  color: $code-color;
+  word-break: break-word;
+
+  // Streamline the style when inside anchors to avoid broken underline and more
+  a > & {
+    color: inherit;
+  }
+}
+
+// User input typically entered via keyboard
+kbd {
+  padding: $kbd-padding-y $kbd-padding-x;
+  font-size: $kbd-font-size;
+  color: $kbd-color;
+  background-color: $kbd-bg;
+  @include border-radius($border-radius-sm);
+  @include box-shadow($kbd-box-shadow);
+
+  kbd {
+    padding: 0;
+    font-size: 100%;
+    font-weight: $nested-kbd-font-weight;
+    @include box-shadow(none);
+  }
+}
+
+// Blocks of code
+pre {
+  display: block;
+  font-size: $code-font-size;
+  color: $pre-color;
+
+  // Account for some code outputs that place code tags in pre tags
+  code {
+    font-size: inherit;
+    color: inherit;
+    word-break: normal;
+  }
+}
+
+// Enable scrollable blocks of code
+.pre-scrollable {
+  max-height: $pre-scrollable-max-height;
+  overflow-y: scroll;
+}

+ 433 - 297
src/client/styles/bootstrap4/_custom-forms.scss

@@ -1,297 +1,433 @@
-// Embedded icons from Open Iconic.
-// Released under MIT and copyright 2014 Waybury.
-// https://useiconic.com/open
-
-
-// Checkboxes and radios
-//
-// Base class takes care of all the key behavioral aspects.
-
-.custom-control {
-  position: relative;
-  display: block;
-  min-height: (1rem * $line-height-base);
-  padding-left: $custom-control-gutter;
-}
-
-.custom-control-inline {
-  display: inline-flex;
-  margin-right: $custom-control-spacer-x;
-}
-
-.custom-control-input {
-  position: absolute;
-  z-index: -1; // Put the input behind the label so it doesn't overlay text
-  opacity: 0;
-
-  &:checked ~ .custom-control-label::before {
-    color: $custom-control-indicator-checked-color;
-    @include gradient-bg($custom-control-indicator-checked-bg);
-    @include box-shadow($custom-control-indicator-checked-box-shadow);
-  }
-
-  &:focus ~ .custom-control-label::before {
-    // the mixin is not used here to make sure there is feedback
-    box-shadow: $custom-control-indicator-focus-box-shadow;
-  }
-
-  &:active ~ .custom-control-label::before {
-    color: $custom-control-indicator-active-color;
-    background-color: $custom-control-indicator-active-bg;
-    @include box-shadow($custom-control-indicator-active-box-shadow);
-  }
-
-  &:disabled {
-    ~ .custom-control-label {
-      color: $custom-control-label-disabled-color;
-
-      &::before {
-        background-color: $custom-control-indicator-disabled-bg;
-      }
-    }
-  }
-}
-
-// Custom control indicators
-//
-// Build the custom controls out of psuedo-elements.
-
-.custom-control-label {
-  margin-bottom: 0;
-
-  // Background-color and (when enabled) gradient
-  &::before {
-    position: absolute;
-    top: (($line-height-base - $custom-control-indicator-size) / 2);
-    left: 0;
-    display: block;
-    width: $custom-control-indicator-size;
-    height: $custom-control-indicator-size;
-    pointer-events: none;
-    content: "";
-    user-select: none;
-    background-color: $custom-control-indicator-bg;
-    @include box-shadow($custom-control-indicator-box-shadow);
-  }
-
-  // Foreground (icon)
-  &::after {
-    position: absolute;
-    top: (($line-height-base - $custom-control-indicator-size) / 2);
-    left: 0;
-    display: block;
-    width: $custom-control-indicator-size;
-    height: $custom-control-indicator-size;
-    content: "";
-    background-repeat: no-repeat;
-    background-position: center center;
-    background-size: $custom-control-indicator-bg-size;
-  }
-}
-
-
-// Checkboxes
-//
-// Tweak just a few things for checkboxes.
-
-.custom-checkbox {
-  .custom-control-label::before {
-    @include border-radius($custom-checkbox-indicator-border-radius);
-  }
-
-  .custom-control-input:checked ~ .custom-control-label {
-    &::before {
-      @include gradient-bg($custom-control-indicator-checked-bg);
-    }
-    &::after {
-      background-image: $custom-checkbox-indicator-icon-checked;
-    }
-  }
-
-  .custom-control-input:indeterminate ~ .custom-control-label {
-    &::before {
-      @include gradient-bg($custom-checkbox-indicator-indeterminate-bg);
-      @include box-shadow($custom-checkbox-indicator-indeterminate-box-shadow);
-    }
-    &::after {
-      background-image: $custom-checkbox-indicator-icon-indeterminate;
-    }
-  }
-
-  .custom-control-input:disabled {
-    &:checked ~ .custom-control-label::before {
-      background-color: $custom-control-indicator-checked-disabled-bg;
-    }
-    &:indeterminate ~ .custom-control-label::before {
-      background-color: $custom-control-indicator-checked-disabled-bg;
-    }
-  }
-}
-
-// Radios
-//
-// Tweak just a few things for radios.
-
-.custom-radio {
-  .custom-control-label::before {
-    border-radius: $custom-radio-indicator-border-radius;
-  }
-
-  .custom-control-input:checked ~ .custom-control-label {
-    &::before {
-      @include gradient-bg($custom-control-indicator-checked-bg);
-    }
-    &::after {
-      background-image: $custom-radio-indicator-icon-checked;
-    }
-  }
-
-  .custom-control-input:disabled {
-    &:checked ~ .custom-control-label::before {
-      background-color: $custom-control-indicator-checked-disabled-bg;
-    }
-  }
-}
-
-
-// Select
-//
-// Replaces the browser default select with a custom one, mostly pulled from
-// http://primercss.io.
-//
-
-.custom-select {
-  display: inline-block;
-  width: 100%;
-  height: $custom-select-height;
-  padding: $custom-select-padding-y ($custom-select-padding-x + $custom-select-indicator-padding) $custom-select-padding-y $custom-select-padding-x;
-  line-height: $custom-select-line-height;
-  color: $custom-select-color;
-  vertical-align: middle;
-  background: $custom-select-bg $custom-select-indicator no-repeat right $custom-select-padding-x center;
-  background-size: $custom-select-bg-size;
-  border: $custom-select-border-width solid $custom-select-border-color;
-  @if $enable-rounded {
-    border-radius: $custom-select-border-radius;
-  } @else {
-    border-radius: 0;
-  }
-  appearance: none;
-
-  &:focus {
-    border-color: $custom-select-focus-border-color;
-    outline: 0;
-    box-shadow: $custom-select-focus-box-shadow;
-
-    &::-ms-value {
-      // For visual consistency with other platforms/browsers,
-      // suppress the default white text on blue background highlight given to
-      // the selected option text when the (still closed) <select> receives focus
-      // in IE and (under certain conditions) Edge.
-      // See https://github.com/twbs/bootstrap/issues/19398.
-      color: $input-color;
-      background-color: $input-bg;
-    }
-  }
-
-  &[multiple],
-  &[size]:not([size="1"]) {
-    height: auto;
-    padding-right: $custom-select-padding-x;
-    background-image: none;
-  }
-
-  &:disabled {
-    color: $custom-select-disabled-color;
-    background-color: $custom-select-disabled-bg;
-  }
-
-  // Hides the default caret in IE11
-  &::-ms-expand {
-    opacity: 0;
-  }
-}
-
-.custom-select-sm {
-  height: $custom-select-height-sm;
-  padding-top: $custom-select-padding-y;
-  padding-bottom: $custom-select-padding-y;
-  font-size: $custom-select-font-size-sm;
-}
-
-.custom-select-lg {
-  height: $custom-select-height-lg;
-  padding-top: $custom-select-padding-y;
-  padding-bottom: $custom-select-padding-y;
-  font-size: $custom-select-font-size-lg;
-}
-
-
-// File
-//
-// Custom file input.
-
-.custom-file {
-  position: relative;
-  display: inline-block;
-  width: 100%;
-  height: $custom-file-height;
-  margin-bottom: 0;
-}
-
-.custom-file-input {
-  position: relative;
-  z-index: 2;
-  width: 100%;
-  height: $custom-file-height;
-  margin: 0;
-  opacity: 0;
-
-  &:focus ~ .custom-file-control {
-    border-color: $custom-file-focus-border-color;
-    box-shadow: $custom-file-focus-box-shadow;
-
-    &::before {
-      border-color: $custom-file-focus-border-color;
-    }
-  }
-
-  @each $lang, $value in $custom-file-text {
-    &:lang(#{$lang}) ~ .custom-file-label::after {
-      content: $value;
-    }
-  }
-}
-
-.custom-file-label {
-  position: absolute;
-  top: 0;
-  right: 0;
-  left: 0;
-  z-index: 1;
-  height: $custom-file-height;
-  padding: $custom-file-padding-y $custom-file-padding-x;
-  line-height: $custom-file-line-height;
-  color: $custom-file-color;
-  background-color: $custom-file-bg;
-  border: $custom-file-border-width solid $custom-file-border-color;
-  @include border-radius($custom-file-border-radius);
-  @include box-shadow($custom-file-box-shadow);
-
-  &::after {
-    position: absolute;
-    top: 0;
-    right: 0;
-    bottom: 0;
-    z-index: 3;
-    display: block;
-    height: calc(#{$custom-file-height} - #{$custom-file-border-width} * 2);
-    padding: $custom-file-padding-y $custom-file-padding-x;
-    line-height: $custom-file-line-height;
-    color: $custom-file-button-color;
-    content: "Browse";
-    @include gradient-bg($custom-file-button-bg);
-    border-left: $custom-file-border-width solid $custom-file-border-color;
-    @include border-radius(0 $custom-file-border-radius $custom-file-border-radius 0);
-  }
-}
+// Embedded icons from Open Iconic.
+// Released under MIT and copyright 2014 Waybury.
+// https://useiconic.com/open
+
+
+// Checkboxes and radios
+//
+// Base class takes care of all the key behavioral aspects.
+
+.custom-control {
+  position: relative;
+  display: block;
+  min-height: ($font-size-base * $line-height-base);
+  padding-left: $custom-control-gutter;
+}
+
+.custom-control-inline {
+  display: inline-flex;
+  margin-right: $custom-control-spacer-x;
+}
+
+.custom-control-input {
+  position: absolute;
+  z-index: -1; // Put the input behind the label so it doesn't overlay text
+  opacity: 0;
+
+  &:checked ~ .custom-control-label::before {
+    color: $custom-control-indicator-checked-color;
+    @include gradient-bg($custom-control-indicator-checked-bg);
+    @include box-shadow($custom-control-indicator-checked-box-shadow);
+  }
+
+  &:focus ~ .custom-control-label::before {
+    // the mixin is not used here to make sure there is feedback
+    box-shadow: $custom-control-indicator-focus-box-shadow;
+  }
+
+  &:active ~ .custom-control-label::before {
+    color: $custom-control-indicator-active-color;
+    background-color: $custom-control-indicator-active-bg;
+    @include box-shadow($custom-control-indicator-active-box-shadow);
+  }
+
+  &:disabled {
+    ~ .custom-control-label {
+      color: $custom-control-label-disabled-color;
+
+      &::before {
+        background-color: $custom-control-indicator-disabled-bg;
+      }
+    }
+  }
+}
+
+// Custom control indicators
+//
+// Build the custom controls out of pseudo-elements.
+
+.custom-control-label {
+  position: relative;
+  margin-bottom: 0;
+
+  // Background-color and (when enabled) gradient
+  &::before {
+    position: absolute;
+    top: (($font-size-base * $line-height-base - $custom-control-indicator-size) / 2);
+    left: -$custom-control-gutter;
+    display: block;
+    width: $custom-control-indicator-size;
+    height: $custom-control-indicator-size;
+    pointer-events: none;
+    content: "";
+    user-select: none;
+    background-color: $custom-control-indicator-bg;
+    @include box-shadow($custom-control-indicator-box-shadow);
+  }
+
+  // Foreground (icon)
+  &::after {
+    position: absolute;
+    top: (($font-size-base * $line-height-base - $custom-control-indicator-size) / 2);
+    left: -$custom-control-gutter;
+    display: block;
+    width: $custom-control-indicator-size;
+    height: $custom-control-indicator-size;
+    content: "";
+    background-repeat: no-repeat;
+    background-position: center center;
+    background-size: $custom-control-indicator-bg-size;
+  }
+}
+
+
+// Checkboxes
+//
+// Tweak just a few things for checkboxes.
+
+.custom-checkbox {
+  .custom-control-label::before {
+    @include border-radius($custom-checkbox-indicator-border-radius);
+  }
+
+  .custom-control-input:checked ~ .custom-control-label {
+    &::before {
+      @include gradient-bg($custom-control-indicator-checked-bg);
+    }
+    &::after {
+      background-image: $custom-checkbox-indicator-icon-checked;
+    }
+  }
+
+  .custom-control-input:indeterminate ~ .custom-control-label {
+    &::before {
+      @include gradient-bg($custom-checkbox-indicator-indeterminate-bg);
+      @include box-shadow($custom-checkbox-indicator-indeterminate-box-shadow);
+    }
+    &::after {
+      background-image: $custom-checkbox-indicator-icon-indeterminate;
+    }
+  }
+
+  .custom-control-input:disabled {
+    &:checked ~ .custom-control-label::before {
+      background-color: $custom-control-indicator-checked-disabled-bg;
+    }
+    &:indeterminate ~ .custom-control-label::before {
+      background-color: $custom-control-indicator-checked-disabled-bg;
+    }
+  }
+}
+
+// Radios
+//
+// Tweak just a few things for radios.
+
+.custom-radio {
+  .custom-control-label::before {
+    border-radius: $custom-radio-indicator-border-radius;
+  }
+
+  .custom-control-input:checked ~ .custom-control-label {
+    &::before {
+      @include gradient-bg($custom-control-indicator-checked-bg);
+    }
+    &::after {
+      background-image: $custom-radio-indicator-icon-checked;
+    }
+  }
+
+  .custom-control-input:disabled {
+    &:checked ~ .custom-control-label::before {
+      background-color: $custom-control-indicator-checked-disabled-bg;
+    }
+  }
+}
+
+
+// Select
+//
+// Replaces the browser default select with a custom one, mostly pulled from
+// https://primer.github.io/.
+//
+
+.custom-select {
+  display: inline-block;
+  width: 100%;
+  height: $custom-select-height;
+  padding: $custom-select-padding-y ($custom-select-padding-x + $custom-select-indicator-padding) $custom-select-padding-y $custom-select-padding-x;
+  line-height: $custom-select-line-height;
+  color: $custom-select-color;
+  vertical-align: middle;
+  background: $custom-select-bg $custom-select-indicator no-repeat right $custom-select-padding-x center;
+  background-size: $custom-select-bg-size;
+  border: $custom-select-border-width solid $custom-select-border-color;
+  @if $enable-rounded {
+    border-radius: $custom-select-border-radius;
+  } @else {
+    border-radius: 0;
+  }
+  @include box-shadow($custom-select-box-shadow);
+  appearance: none;
+
+  &:focus {
+    border-color: $custom-select-focus-border-color;
+    outline: 0;
+    @if $enable-shadows {
+      box-shadow: $custom-select-box-shadow, $custom-select-focus-box-shadow;
+    } @else {
+      box-shadow: $custom-select-focus-box-shadow;
+    }
+
+    &::-ms-value {
+      // For visual consistency with other platforms/browsers,
+      // suppress the default white text on blue background highlight given to
+      // the selected option text when the (still closed) <select> receives focus
+      // in IE and (under certain conditions) Edge.
+      // See https://github.com/twbs/bootstrap/issues/19398.
+      color: $input-color;
+      background-color: $input-bg;
+    }
+  }
+
+  &[multiple],
+  &[size]:not([size="1"]) {
+    height: auto;
+    padding-right: $custom-select-padding-x;
+    background-image: none;
+  }
+
+  &:disabled {
+    color: $custom-select-disabled-color;
+    background-color: $custom-select-disabled-bg;
+  }
+
+  // Hides the default caret in IE11
+  &::-ms-expand {
+    opacity: 0;
+  }
+}
+
+.custom-select-sm {
+  height: $custom-select-height-sm;
+  padding-top: $custom-select-padding-y;
+  padding-bottom: $custom-select-padding-y;
+  font-size: $custom-select-font-size-sm;
+}
+
+.custom-select-lg {
+  height: $custom-select-height-lg;
+  padding-top: $custom-select-padding-y;
+  padding-bottom: $custom-select-padding-y;
+  font-size: $custom-select-font-size-lg;
+}
+
+
+// File
+//
+// Custom file input.
+
+.custom-file {
+  position: relative;
+  display: inline-block;
+  width: 100%;
+  height: $custom-file-height;
+  margin-bottom: 0;
+}
+
+.custom-file-input {
+  position: relative;
+  z-index: 2;
+  width: 100%;
+  height: $custom-file-height;
+  margin: 0;
+  opacity: 0;
+
+  &:focus ~ .custom-file-label {
+    border-color: $custom-file-focus-border-color;
+    box-shadow: $custom-file-focus-box-shadow;
+
+    &::after {
+      border-color: $custom-file-focus-border-color;
+    }
+  }
+
+  &:disabled ~ .custom-file-label {
+    background-color: $custom-file-disabled-bg;
+  }
+
+  @each $lang, $value in $custom-file-text {
+    &:lang(#{$lang}) ~ .custom-file-label::after {
+      content: $value;
+    }
+  }
+}
+
+.custom-file-label {
+  position: absolute;
+  top: 0;
+  right: 0;
+  left: 0;
+  z-index: 1;
+  height: $custom-file-height;
+  padding: $custom-file-padding-y $custom-file-padding-x;
+  line-height: $custom-file-line-height;
+  color: $custom-file-color;
+  background-color: $custom-file-bg;
+  border: $custom-file-border-width solid $custom-file-border-color;
+  @include border-radius($custom-file-border-radius);
+  @include box-shadow($custom-file-box-shadow);
+
+  &::after {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 3;
+    display: block;
+    height: $custom-file-height-inner;
+    padding: $custom-file-padding-y $custom-file-padding-x;
+    line-height: $custom-file-line-height;
+    color: $custom-file-button-color;
+    content: "Browse";
+    @include gradient-bg($custom-file-button-bg);
+    border-left: $custom-file-border-width solid $custom-file-border-color;
+    @include border-radius(0 $custom-file-border-radius $custom-file-border-radius 0);
+  }
+}
+
+// Range
+//
+// Style range inputs the same across browsers. Vendor-specific rules for pseudo
+// elements cannot be mixed. As such, there are no shared styles for focus or
+// active states on prefixed selectors.
+
+.custom-range {
+  width: 100%;
+  padding-left: 0; // Firefox specific
+  background-color: transparent;
+  appearance: none;
+
+  &:focus {
+    outline: none;
+
+    // Pseudo-elements must be split across multiple rulesets to have an affect.
+    // No box-shadow() mixin for focus accessibility.
+    &::-webkit-slider-thumb { box-shadow: $custom-range-thumb-focus-box-shadow; }
+    &::-moz-range-thumb     { box-shadow: $custom-range-thumb-focus-box-shadow; }
+    &::-ms-thumb            { box-shadow: $custom-range-thumb-focus-box-shadow; }
+  }
+
+  &::-moz-focus-outer {
+    border: 0;
+  }
+
+  &::-webkit-slider-thumb {
+    width: $custom-range-thumb-width;
+    height: $custom-range-thumb-height;
+    margin-top: (($custom-range-track-height - $custom-range-thumb-height) / 2); // Webkit specific
+    @include gradient-bg($custom-range-thumb-bg);
+    border: $custom-range-thumb-border;
+    @include border-radius($custom-range-thumb-border-radius);
+    @include box-shadow($custom-range-thumb-box-shadow);
+    @include transition($custom-forms-transition);
+    appearance: none;
+
+    &:active {
+      @include gradient-bg($custom-range-thumb-active-bg);
+    }
+  }
+
+  &::-webkit-slider-runnable-track {
+    width: $custom-range-track-width;
+    height: $custom-range-track-height;
+    color: transparent; // Why?
+    cursor: $custom-range-track-cursor;
+    background-color: $custom-range-track-bg;
+    border-color: transparent;
+    @include border-radius($custom-range-track-border-radius);
+    @include box-shadow($custom-range-track-box-shadow);
+  }
+
+  &::-moz-range-thumb {
+    width: $custom-range-thumb-width;
+    height: $custom-range-thumb-height;
+    @include gradient-bg($custom-range-thumb-bg);
+    border: $custom-range-thumb-border;
+    @include border-radius($custom-range-thumb-border-radius);
+    @include box-shadow($custom-range-thumb-box-shadow);
+    @include transition($custom-forms-transition);
+    appearance: none;
+
+    &:active {
+      @include gradient-bg($custom-range-thumb-active-bg);
+    }
+  }
+
+  &::-moz-range-track {
+    width: $custom-range-track-width;
+    height: $custom-range-track-height;
+    color: transparent;
+    cursor: $custom-range-track-cursor;
+    background-color: $custom-range-track-bg;
+    border-color: transparent; // Firefox specific?
+    @include border-radius($custom-range-track-border-radius);
+    @include box-shadow($custom-range-track-box-shadow);
+  }
+
+  &::-ms-thumb {
+    width: $custom-range-thumb-width;
+    height: $custom-range-thumb-height;
+    margin-top: 0; // Edge specific
+    margin-right: $custom-range-thumb-focus-box-shadow-width; // Workaround that overflowed box-shadow is hidden.
+    margin-left: $custom-range-thumb-focus-box-shadow-width;  // Workaround that overflowed box-shadow is hidden.
+    @include gradient-bg($custom-range-thumb-bg);
+    border: $custom-range-thumb-border;
+    @include border-radius($custom-range-thumb-border-radius);
+    @include box-shadow($custom-range-thumb-box-shadow);
+    @include transition($custom-forms-transition);
+    appearance: none;
+
+    &:active {
+      @include gradient-bg($custom-range-thumb-active-bg);
+    }
+  }
+
+  &::-ms-track {
+    width: $custom-range-track-width;
+    height: $custom-range-track-height;
+    color: transparent;
+    cursor: $custom-range-track-cursor;
+    background-color: transparent;
+    border-color: transparent;
+    border-width: ($custom-range-thumb-height * .5);
+    @include box-shadow($custom-range-track-box-shadow);
+  }
+
+  &::-ms-fill-lower {
+    background-color: $custom-range-track-bg;
+    @include border-radius($custom-range-track-border-radius);
+  }
+
+  &::-ms-fill-upper {
+    margin-right: 15px; // arbitrary?
+    background-color: $custom-range-track-bg;
+    @include border-radius($custom-range-track-border-radius);
+  }
+}
+
+.custom-control-label::before,
+.custom-file-label,
+.custom-select {
+  @include transition($custom-forms-transition);
+}

+ 166 - 131
src/client/styles/bootstrap4/_dropdown.scss

@@ -1,131 +1,166 @@
-// The dropdown wrapper (`<div>`)
-.dropup,
-.dropdown {
-  position: relative;
-}
-
-.dropdown-toggle {
-  // Generate the caret automatically
-  @include caret;
-}
-
-// The dropdown menu
-.dropdown-menu {
-  position: absolute;
-  top: 100%;
-  left: 0;
-  z-index: $zindex-dropdown;
-  display: none; // none by default, but block on "open" of the menu
-  float: left;
-  min-width: $dropdown-min-width;
-  padding: $dropdown-padding-y 0;
-  margin: $dropdown-spacer 0 0; // override default ul
-  font-size: $font-size-base; // Redeclare because nesting can cause inheritance issues
-  color: $body-color;
-  text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)
-  list-style: none;
-  background-color: $dropdown-bg;
-  background-clip: padding-box;
-  border: $dropdown-border-width solid $dropdown-border-color;
-  @include border-radius($dropdown-border-radius);
-  @include box-shadow($dropdown-box-shadow);
-}
-
-// Allow for dropdowns to go bottom up (aka, dropup-menu)
-// Just add .dropup after the standard .dropdown class and you're set.
-.dropup {
-  .dropdown-menu {
-    margin-top: 0;
-    margin-bottom: $dropdown-spacer;
-  }
-
-  .dropdown-toggle {
-    @include caret(up);
-  }
-}
-
-.dropright {
-  .dropdown-menu {
-    margin-top: 0;
-    margin-left: $dropdown-spacer;
-  }
-
-  .dropdown-toggle {
-    @include caret(right);
-    &::after {
-      vertical-align: 0;
-    }
-  }
-}
-
-.dropleft {
-  .dropdown-menu {
-    margin-top: 0;
-    margin-right: $dropdown-spacer;
-  }
-
-  .dropdown-toggle {
-    @include caret(left);
-    &::before {
-      vertical-align: 0;
-    }
-  }
-}
-
-// Dividers (basically an `<hr>`) within the dropdown
-.dropdown-divider {
-  @include nav-divider($dropdown-divider-bg);
-}
-
-// Links, buttons, and more within the dropdown menu
-//
-// `<button>`-specific styles are denoted with `// For <button>s`
-.dropdown-item {
-  display: block;
-  width: 100%; // For `<button>`s
-  padding: $dropdown-item-padding-y $dropdown-item-padding-x;
-  clear: both;
-  font-weight: $font-weight-normal;
-  color: $dropdown-link-color;
-  text-align: inherit; // For `<button>`s
-  white-space: nowrap; // prevent links from randomly breaking onto new lines
-  background-color: transparent; // For `<button>`s
-  border: 0; // For `<button>`s
-
-  @include hover-focus {
-    color: $dropdown-link-hover-color;
-    text-decoration: none;
-    @include gradient-bg($dropdown-link-hover-bg);
-  }
-
-  &.active,
-  &:active {
-    color: $dropdown-link-active-color;
-    text-decoration: none;
-    @include gradient-bg($dropdown-link-active-bg);
-  }
-
-  &.disabled,
-  &:disabled {
-    color: $dropdown-link-disabled-color;
-    background-color: transparent;
-    // Remove CSS gradients if they're enabled
-    @if $enable-gradients {
-      background-image: none;
-    }
-  }
-}
-
-.dropdown-menu.show {
-  display: block;
-}
-
-// Dropdown section headers
-.dropdown-header {
-  display: block;
-  padding: $dropdown-padding-y $dropdown-item-padding-x;
-  margin-bottom: 0; // for use with heading elements
-  font-size: $font-size-sm;
-  color: $dropdown-header-color;
-  white-space: nowrap; // as with > li > a
-}
+// The dropdown wrapper (`<div>`)
+.dropup,
+.dropright,
+.dropdown,
+.dropleft {
+  position: relative;
+}
+
+.dropdown-toggle {
+  // Generate the caret automatically
+  @include caret;
+}
+
+// The dropdown menu
+.dropdown-menu {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  z-index: $zindex-dropdown;
+  display: none; // none by default, but block on "open" of the menu
+  float: left;
+  min-width: $dropdown-min-width;
+  padding: $dropdown-padding-y 0;
+  margin: $dropdown-spacer 0 0; // override default ul
+  font-size: $font-size-base; // Redeclare because nesting can cause inheritance issues
+  color: $body-color;
+  text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)
+  list-style: none;
+  background-color: $dropdown-bg;
+  background-clip: padding-box;
+  border: $dropdown-border-width solid $dropdown-border-color;
+  @include border-radius($dropdown-border-radius);
+  @include box-shadow($dropdown-box-shadow);
+}
+
+.dropdown-menu-right {
+  right: 0;
+  left: auto;
+}
+
+// Allow for dropdowns to go bottom up (aka, dropup-menu)
+// Just add .dropup after the standard .dropdown class and you're set.
+.dropup {
+  .dropdown-menu {
+    top: auto;
+    bottom: 100%;
+    margin-top: 0;
+    margin-bottom: $dropdown-spacer;
+  }
+
+  .dropdown-toggle {
+    @include caret(up);
+  }
+}
+
+.dropright {
+  .dropdown-menu {
+    top: 0;
+    right: auto;
+    left: 100%;
+    margin-top: 0;
+    margin-left: $dropdown-spacer;
+  }
+
+  .dropdown-toggle {
+    @include caret(right);
+    &::after {
+      vertical-align: 0;
+    }
+  }
+}
+
+.dropleft {
+  .dropdown-menu {
+    top: 0;
+    right: 100%;
+    left: auto;
+    margin-top: 0;
+    margin-right: $dropdown-spacer;
+  }
+
+  .dropdown-toggle {
+    @include caret(left);
+    &::before {
+      vertical-align: 0;
+    }
+  }
+}
+
+// When enabled Popper.js, reset basic dropdown position
+// stylelint-disable no-duplicate-selectors
+.dropdown-menu {
+  &[x-placement^="top"],
+  &[x-placement^="right"],
+  &[x-placement^="bottom"],
+  &[x-placement^="left"] {
+    right: auto;
+    bottom: auto;
+  }
+}
+// stylelint-enable no-duplicate-selectors
+
+// Dividers (basically an `<hr>`) within the dropdown
+.dropdown-divider {
+  @include nav-divider($dropdown-divider-bg);
+}
+
+// Links, buttons, and more within the dropdown menu
+//
+// `<button>`-specific styles are denoted with `// For <button>s`
+.dropdown-item {
+  display: block;
+  width: 100%; // For `<button>`s
+  padding: $dropdown-item-padding-y $dropdown-item-padding-x;
+  clear: both;
+  font-weight: $font-weight-normal;
+  color: $dropdown-link-color;
+  text-align: inherit; // For `<button>`s
+  white-space: nowrap; // prevent links from randomly breaking onto new lines
+  background-color: transparent; // For `<button>`s
+  border: 0; // For `<button>`s
+
+  @include hover-focus {
+    color: $dropdown-link-hover-color;
+    text-decoration: none;
+    @include gradient-bg($dropdown-link-hover-bg);
+  }
+
+  &.active,
+  &:active {
+    color: $dropdown-link-active-color;
+    text-decoration: none;
+    @include gradient-bg($dropdown-link-active-bg);
+  }
+
+  &.disabled,
+  &:disabled {
+    color: $dropdown-link-disabled-color;
+    background-color: transparent;
+    // Remove CSS gradients if they're enabled
+    @if $enable-gradients {
+      background-image: none;
+    }
+  }
+}
+
+.dropdown-menu.show {
+  display: block;
+}
+
+// Dropdown section headers
+.dropdown-header {
+  display: block;
+  padding: $dropdown-padding-y $dropdown-item-padding-x;
+  margin-bottom: 0; // for use with heading elements
+  font-size: $font-size-sm;
+  color: $dropdown-header-color;
+  white-space: nowrap; // as with > li > a
+}
+
+// Dropdown text
+.dropdown-item-text {
+  display: block;
+  padding: $dropdown-item-padding-y $dropdown-item-padding-x;
+  color: $dropdown-link-color;
+}

+ 333 - 333
src/client/styles/bootstrap4/_forms.scss

@@ -1,333 +1,333 @@
-// stylelint-disable selector-no-qualifying-type
-
-//
-// Textual form controls
-//
-
-.form-control {
-  display: block;
-  width: 100%;
-  padding: $input-padding-y $input-padding-x;
-  font-size: $font-size-base;
-  line-height: $input-line-height;
-  color: $input-color;
-  background-color: $input-bg;
-  background-clip: padding-box;
-  border: $input-border-width solid $input-border-color;
-
-  // Note: This has no effect on <select>s in some browsers, due to the limited stylability of `<select>`s in CSS.
-  @if $enable-rounded {
-    // Manually use the if/else instead of the mixin to account for iOS override
-    border-radius: $input-border-radius;
-  } @else {
-    // Otherwise undo the iOS default
-    border-radius: 0;
-  }
-
-  @include box-shadow($input-box-shadow);
-  @include transition($input-transition);
-
-  // Unstyle the caret on `<select>`s in IE10+.
-  &::-ms-expand {
-    background-color: transparent;
-    border: 0;
-  }
-
-  // Customize the `:focus` state to imitate native WebKit styles.
-  @include form-control-focus();
-
-  // Placeholder
-  &::placeholder {
-    color: $input-placeholder-color;
-    // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526.
-    opacity: 1;
-  }
-
-  // Disabled and read-only inputs
-  //
-  // HTML5 says that controls under a fieldset > legend:first-child won't be
-  // disabled if the fieldset is disabled. Due to implementation difficulty, we
-  // don't honor that edge case; we style them as disabled anyway.
-  &:disabled,
-  &[readonly] {
-    background-color: $input-disabled-bg;
-    // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655.
-    opacity: 1;
-  }
-}
-
-select.form-control {
-  &:not([size]):not([multiple]) {
-    height: $input-height;
-  }
-
-  &:focus::-ms-value {
-    // Suppress the nested default white text on blue background highlight given to
-    // the selected option text when the (still closed) <select> receives focus
-    // in IE and (under certain conditions) Edge, as it looks bad and cannot be made to
-    // match the appearance of the native widget.
-    // See https://github.com/twbs/bootstrap/issues/19398.
-    color: $input-color;
-    background-color: $input-bg;
-  }
-}
-
-// Make file inputs better match text inputs by forcing them to new lines.
-.form-control-file,
-.form-control-range {
-  display: block;
-  width: 100%;
-}
-
-
-//
-// Labels
-//
-
-// For use with horizontal and inline forms, when you need the label (or legend)
-// text to align with the form controls.
-.col-form-label {
-  padding-top: calc(#{$input-padding-y} + #{$input-border-width});
-  padding-bottom: calc(#{$input-padding-y} + #{$input-border-width});
-  margin-bottom: 0; // Override the `<label>/<legend>` default
-  font-size: inherit; // Override the `<legend>` default
-  line-height: $input-line-height;
-}
-
-.col-form-label-lg {
-  padding-top: calc(#{$input-padding-y-lg} + #{$input-border-width});
-  padding-bottom: calc(#{$input-padding-y-lg} + #{$input-border-width});
-  font-size: $font-size-lg;
-  line-height: $input-line-height-lg;
-}
-
-.col-form-label-sm {
-  padding-top: calc(#{$input-padding-y-sm} + #{$input-border-width});
-  padding-bottom: calc(#{$input-padding-y-sm} + #{$input-border-width});
-  font-size: $font-size-sm;
-  line-height: $input-line-height-sm;
-}
-
-
-// Readonly controls as plain text
-//
-// Apply class to a readonly input to make it appear like regular plain
-// text (without any border, background color, focus indicator)
-
-.form-control-plaintext {
-  display: block;
-  width: 100%;
-  padding-top: $input-padding-y;
-  padding-bottom: $input-padding-y;
-  margin-bottom: 0; // match inputs if this class comes on inputs with default margins
-  line-height: $input-line-height;
-  background-color: transparent;
-  border: solid transparent;
-  border-width: $input-border-width 0;
-
-  &.form-control-sm,
-  &.form-control-lg {
-    padding-right: 0;
-    padding-left: 0;
-  }
-}
-
-
-// Form control sizing
-//
-// Build on `.form-control` with modifier classes to decrease or increase the
-// height and font-size of form controls.
-//
-// The `.form-group-* form-control` variations are sadly duplicated to avoid the
-// issue documented in https://github.com/twbs/bootstrap/issues/15074.
-
-.form-control-sm {
-  padding: $input-padding-y-sm $input-padding-x-sm;
-  font-size: $font-size-sm;
-  line-height: $input-line-height-sm;
-  @include border-radius($input-border-radius-sm);
-}
-
-select.form-control-sm {
-  &:not([size]):not([multiple]) {
-    height: $input-height-sm;
-  }
-}
-
-.form-control-lg {
-  padding: $input-padding-y-lg $input-padding-x-lg;
-  font-size: $font-size-lg;
-  line-height: $input-line-height-lg;
-  @include border-radius($input-border-radius-lg);
-}
-
-select.form-control-lg {
-  &:not([size]):not([multiple]) {
-    height: $input-height-lg;
-  }
-}
-
-
-// Form groups
-//
-// Designed to help with the organization and spacing of vertical forms. For
-// horizontal forms, use the predefined grid classes.
-
-.form-group {
-  margin-bottom: $form-group-margin-bottom;
-}
-
-.form-text {
-  display: block;
-  margin-top: $form-text-margin-top;
-}
-
-
-// Form grid
-//
-// Special replacement for our grid system's `.row` for tighter form layouts.
-
-.form-row {
-  display: flex;
-  flex-wrap: wrap;
-  margin-right: -5px;
-  margin-left: -5px;
-
-  > .col,
-  > [class*="col-"] {
-    padding-right: 5px;
-    padding-left: 5px;
-  }
-}
-
-
-// Checkboxes and radios
-//
-// Indent the labels to position radios/checkboxes as hanging controls.
-
-.form-check {
-  position: relative;
-  display: block;
-  padding-left: $form-check-input-gutter;
-}
-
-.form-check-input {
-  position: absolute;
-  margin-top: $form-check-input-margin-y;
-  margin-left: -$form-check-input-gutter;
-
-  &:disabled ~ .form-check-label {
-    color: $text-muted;
-  }
-}
-
-.form-check-label {
-  margin-bottom: 0; // Override default `<label>` bottom margin
-}
-
-.form-check-inline {
-  display: inline-flex;
-  align-items: center;
-  padding-left: 0; // Override base .form-check
-  margin-right: $form-check-inline-margin-x;
-
-  // Undo .form-check-input defaults and add some `margin-right`.
-  .form-check-input {
-    position: static;
-    margin-top: 0;
-    margin-right: $form-check-inline-input-margin-x;
-    margin-left: 0;
-  }
-}
-
-
-// Form validation
-//
-// Provide feedback to users when form field values are valid or invalid. Works
-// primarily for client-side validation via scoped `:invalid` and `:valid`
-// pseudo-classes but also includes `.is-invalid` and `.is-valid` classes for
-// server side validation.
-
-@include form-validation-state("valid", $form-feedback-valid-color);
-@include form-validation-state("invalid", $form-feedback-invalid-color);
-
-// Inline forms
-//
-// Make forms appear inline(-block) by adding the `.form-inline` class. Inline
-// forms begin stacked on extra small (mobile) devices and then go inline when
-// viewports reach <768px.
-//
-// Requires wrapping inputs and labels with `.form-group` for proper display of
-// default HTML form controls and our custom form controls (e.g., input groups).
-
-.form-inline {
-  display: flex;
-  flex-flow: row wrap;
-  align-items: center; // Prevent shorter elements from growing to same height as others (e.g., small buttons growing to normal sized button height)
-
-  // Because we use flex, the initial sizing of checkboxes is collapsed and
-  // doesn't occupy the full-width (which is what we want for xs grid tier),
-  // so we force that here.
-  .form-check {
-    width: 100%;
-  }
-
-  // Kick in the inline
-  @include media-breakpoint-up(sm) {
-    label {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      margin-bottom: 0;
-    }
-
-    // Inline-block all the things for "inline"
-    .form-group {
-      display: flex;
-      flex: 0 0 auto;
-      flex-flow: row wrap;
-      align-items: center;
-      margin-bottom: 0;
-    }
-
-    // Allow folks to *not* use `.form-group`
-    .form-control {
-      display: inline-block;
-      width: auto; // Prevent labels from stacking above inputs in `.form-group`
-      vertical-align: middle;
-    }
-
-    // Make static controls behave like regular ones
-    .form-control-plaintext {
-      display: inline-block;
-    }
-
-    .input-group {
-      width: auto;
-    }
-
-    // Remove default margin on radios/checkboxes that were used for stacking, and
-    // then undo the floating of radios and checkboxes to match.
-    .form-check {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      width: auto;
-      padding-left: 0;
-    }
-    .form-check-input {
-      position: relative;
-      margin-top: 0;
-      margin-right: $form-check-input-margin-x;
-      margin-left: 0;
-    }
-
-    .custom-control {
-      align-items: center;
-      justify-content: center;
-    }
-    .custom-control-label {
-      margin-bottom: 0;
-    }
-  }
-}
+// stylelint-disable selector-no-qualifying-type
+
+//
+// Textual form controls
+//
+
+.form-control {
+  display: block;
+  width: 100%;
+  height: $input-height;
+  padding: $input-padding-y $input-padding-x;
+  font-size: $font-size-base;
+  line-height: $input-line-height;
+  color: $input-color;
+  background-color: $input-bg;
+  background-clip: padding-box;
+  border: $input-border-width solid $input-border-color;
+
+  // Note: This has no effect on <select>s in some browsers, due to the limited stylability of `<select>`s in CSS.
+  @if $enable-rounded {
+    // Manually use the if/else instead of the mixin to account for iOS override
+    border-radius: $input-border-radius;
+  } @else {
+    // Otherwise undo the iOS default
+    border-radius: 0;
+  }
+
+  @include box-shadow($input-box-shadow);
+  @include transition($input-transition);
+
+  // Unstyle the caret on `<select>`s in IE10+.
+  &::-ms-expand {
+    background-color: transparent;
+    border: 0;
+  }
+
+  // Customize the `:focus` state to imitate native WebKit styles.
+  @include form-control-focus();
+
+  // Placeholder
+  &::placeholder {
+    color: $input-placeholder-color;
+    // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526.
+    opacity: 1;
+  }
+
+  // Disabled and read-only inputs
+  //
+  // HTML5 says that controls under a fieldset > legend:first-child won't be
+  // disabled if the fieldset is disabled. Due to implementation difficulty, we
+  // don't honor that edge case; we style them as disabled anyway.
+  &:disabled,
+  &[readonly] {
+    background-color: $input-disabled-bg;
+    // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655.
+    opacity: 1;
+  }
+}
+
+select.form-control {
+  &:focus::-ms-value {
+    // Suppress the nested default white text on blue background highlight given to
+    // the selected option text when the (still closed) <select> receives focus
+    // in IE and (under certain conditions) Edge, as it looks bad and cannot be made to
+    // match the appearance of the native widget.
+    // See https://github.com/twbs/bootstrap/issues/19398.
+    color: $input-color;
+    background-color: $input-bg;
+  }
+}
+
+// Make file inputs better match text inputs by forcing them to new lines.
+.form-control-file,
+.form-control-range {
+  display: block;
+  width: 100%;
+}
+
+
+//
+// Labels
+//
+
+// For use with horizontal and inline forms, when you need the label (or legend)
+// text to align with the form controls.
+.col-form-label {
+  padding-top: calc(#{$input-padding-y} + #{$input-border-width});
+  padding-bottom: calc(#{$input-padding-y} + #{$input-border-width});
+  margin-bottom: 0; // Override the `<label>/<legend>` default
+  font-size: inherit; // Override the `<legend>` default
+  line-height: $input-line-height;
+}
+
+.col-form-label-lg {
+  padding-top: calc(#{$input-padding-y-lg} + #{$input-border-width});
+  padding-bottom: calc(#{$input-padding-y-lg} + #{$input-border-width});
+  font-size: $font-size-lg;
+  line-height: $input-line-height-lg;
+}
+
+.col-form-label-sm {
+  padding-top: calc(#{$input-padding-y-sm} + #{$input-border-width});
+  padding-bottom: calc(#{$input-padding-y-sm} + #{$input-border-width});
+  font-size: $font-size-sm;
+  line-height: $input-line-height-sm;
+}
+
+
+// Readonly controls as plain text
+//
+// Apply class to a readonly input to make it appear like regular plain
+// text (without any border, background color, focus indicator)
+
+.form-control-plaintext {
+  display: block;
+  width: 100%;
+  padding-top: $input-padding-y;
+  padding-bottom: $input-padding-y;
+  margin-bottom: 0; // match inputs if this class comes on inputs with default margins
+  line-height: $input-line-height;
+  color: $input-plaintext-color;
+  background-color: transparent;
+  border: solid transparent;
+  border-width: $input-border-width 0;
+
+  &.form-control-sm,
+  &.form-control-lg {
+    padding-right: 0;
+    padding-left: 0;
+  }
+}
+
+
+// Form control sizing
+//
+// Build on `.form-control` with modifier classes to decrease or increase the
+// height and font-size of form controls.
+//
+// Repeated in `_input_group.scss` to avoid Sass extend issues.
+
+.form-control-sm {
+  height: $input-height-sm;
+  padding: $input-padding-y-sm $input-padding-x-sm;
+  font-size: $font-size-sm;
+  line-height: $input-line-height-sm;
+  @include border-radius($input-border-radius-sm);
+}
+
+.form-control-lg {
+  height: $input-height-lg;
+  padding: $input-padding-y-lg $input-padding-x-lg;
+  font-size: $font-size-lg;
+  line-height: $input-line-height-lg;
+  @include border-radius($input-border-radius-lg);
+}
+
+// stylelint-disable no-duplicate-selectors
+select.form-control {
+  &[size],
+  &[multiple] {
+    height: auto;
+  }
+}
+
+textarea.form-control {
+  height: auto;
+}
+// stylelint-enable no-duplicate-selectors
+
+// Form groups
+//
+// Designed to help with the organization and spacing of vertical forms. For
+// horizontal forms, use the predefined grid classes.
+
+.form-group {
+  margin-bottom: $form-group-margin-bottom;
+}
+
+.form-text {
+  display: block;
+  margin-top: $form-text-margin-top;
+}
+
+
+// Form grid
+//
+// Special replacement for our grid system's `.row` for tighter form layouts.
+
+.form-row {
+  display: flex;
+  flex-wrap: wrap;
+  margin-right: -5px;
+  margin-left: -5px;
+
+  > .col,
+  > [class*="col-"] {
+    padding-right: 5px;
+    padding-left: 5px;
+  }
+}
+
+
+// Checkboxes and radios
+//
+// Indent the labels to position radios/checkboxes as hanging controls.
+
+.form-check {
+  position: relative;
+  display: block;
+  padding-left: $form-check-input-gutter;
+}
+
+.form-check-input {
+  position: absolute;
+  margin-top: $form-check-input-margin-y;
+  margin-left: -$form-check-input-gutter;
+
+  &:disabled ~ .form-check-label {
+    color: $text-muted;
+  }
+}
+
+.form-check-label {
+  margin-bottom: 0; // Override default `<label>` bottom margin
+}
+
+.form-check-inline {
+  display: inline-flex;
+  align-items: center;
+  padding-left: 0; // Override base .form-check
+  margin-right: $form-check-inline-margin-x;
+
+  // Undo .form-check-input defaults and add some `margin-right`.
+  .form-check-input {
+    position: static;
+    margin-top: 0;
+    margin-right: $form-check-inline-input-margin-x;
+    margin-left: 0;
+  }
+}
+
+
+// Form validation
+//
+// Provide feedback to users when form field values are valid or invalid. Works
+// primarily for client-side validation via scoped `:invalid` and `:valid`
+// pseudo-classes but also includes `.is-invalid` and `.is-valid` classes for
+// server side validation.
+
+@include form-validation-state("valid", $form-feedback-valid-color);
+@include form-validation-state("invalid", $form-feedback-invalid-color);
+
+// Inline forms
+//
+// Make forms appear inline(-block) by adding the `.form-inline` class. Inline
+// forms begin stacked on extra small (mobile) devices and then go inline when
+// viewports reach <768px.
+//
+// Requires wrapping inputs and labels with `.form-group` for proper display of
+// default HTML form controls and our custom form controls (e.g., input groups).
+
+.form-inline {
+  display: flex;
+  flex-flow: row wrap;
+  align-items: center; // Prevent shorter elements from growing to same height as others (e.g., small buttons growing to normal sized button height)
+
+  // Because we use flex, the initial sizing of checkboxes is collapsed and
+  // doesn't occupy the full-width (which is what we want for xs grid tier),
+  // so we force that here.
+  .form-check {
+    width: 100%;
+  }
+
+  // Kick in the inline
+  @include media-breakpoint-up(sm) {
+    label {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      margin-bottom: 0;
+    }
+
+    // Inline-block all the things for "inline"
+    .form-group {
+      display: flex;
+      flex: 0 0 auto;
+      flex-flow: row wrap;
+      align-items: center;
+      margin-bottom: 0;
+    }
+
+    // Allow folks to *not* use `.form-group`
+    .form-control {
+      display: inline-block;
+      width: auto; // Prevent labels from stacking above inputs in `.form-group`
+      vertical-align: middle;
+    }
+
+    // Make static controls behave like regular ones
+    .form-control-plaintext {
+      display: inline-block;
+    }
+
+    .input-group,
+    .custom-select {
+      width: auto;
+    }
+
+    // Remove default margin on radios/checkboxes that were used for stacking, and
+    // then undo the floating of radios and checkboxes to match.
+    .form-check {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: auto;
+      padding-left: 0;
+    }
+    .form-check-input {
+      position: relative;
+      margin-top: 0;
+      margin-right: $form-check-input-margin-x;
+      margin-left: 0;
+    }
+
+    .custom-control {
+      align-items: center;
+      justify-content: center;
+    }
+    .custom-control-label {
+      margin-bottom: 0;
+    }
+  }
+}

+ 87 - 87
src/client/styles/bootstrap4/_functions.scss

@@ -1,87 +1,87 @@
-// Bootstrap functions
-//
-// Utility mixins and functions for evalutating source code across our variables, maps, and mixins.
-
-// Ascending
-// Used to evaluate Sass maps like our grid breakpoints.
-@mixin _assert-ascending($map, $map-name) {
-  $prev-key: null;
-  $prev-num: null;
-  @each $key, $num in $map {
-    @if $prev-num == null {
-      // Do nothing
-    } @else if not comparable($prev-num, $num) {
-      @warn "Potentially invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} whose unit makes it incomparable to #{$prev-num}, the value of the previous key '#{$prev-key}' !";
-    } @else if $prev-num >= $num {
-      @warn "Invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} which isn't greater than #{$prev-num}, the value of the previous key '#{$prev-key}' !";
-    }
-    $prev-key: $key;
-    $prev-num: $num;
-  }
-}
-
-// Starts at zero
-// Another grid mixin that ensures the min-width of the lowest breakpoint starts at 0.
-@mixin _assert-starts-at-zero($map) {
-  $values: map-values($map);
-  $first-value: nth($values, 1);
-  @if $first-value != 0 {
-    @warn "First breakpoint in `$grid-breakpoints` must start at 0, but starts at #{$first-value}.";
-  }
-}
-/*
-// Replace `$search` with `$replace` in `$string`
-// Used on our SVG icon backgrounds for custom forms.
-//
-// @author Hugo Giraudel
-// @param {String} $string - Initial string
-// @param {String} $search - Substring to replace
-// @param {String} $replace ('') - New value
-// @return {String} - Updated string
-@function str-replace($string, $search, $replace: "") {
-  $index: str-index($string, $search);
-
-  @if $index {
-    @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
-  }
-
-  @return $string;
-}
-
-// Color contrast
-@function color-yiq($color) {
-  $r: red($color);
-  $g: green($color);
-  $b: blue($color);
-
-  $yiq: (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
-
-  @if ($yiq >= $yiq-contrasted-threshold) {
-    @return $yiq-text-dark;
-  } @else {
-    @return $yiq-text-light;
-  }
-}
-
-// Retrieve color Sass maps
-@function color($key: "blue") {
-  @return map-get($colors, $key);
-}
-
-@function theme-color($key: "primary") {
-  @return map-get($theme-colors, $key);
-}
-
-@function gray($key: "100") {
-  @return map-get($grays, $key);
-}
-
-// Request a theme color level
-@function theme-color-level($color-name: "primary", $level: 0) {
-  $color: theme-color($color-name);
-  $color-base: if($level > 0, #000, #fff);
-  $level: abs($level);
-
-  @return mix($color-base, $color, $level * $theme-color-interval);
-}
-*/
+// Bootstrap functions
+//
+// Utility mixins and functions for evaluating source code across our variables, maps, and mixins.
+
+// Ascending
+// Used to evaluate Sass maps like our grid breakpoints.
+@mixin _assert-ascending($map, $map-name) {
+  $prev-key: null;
+  $prev-num: null;
+  @each $key, $num in $map {
+    @if $prev-num == null {
+      // Do nothing
+    } @else if not comparable($prev-num, $num) {
+      @warn "Potentially invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} whose unit makes it incomparable to #{$prev-num}, the value of the previous key '#{$prev-key}' !";
+    } @else if $prev-num >= $num {
+      @warn "Invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} which isn't greater than #{$prev-num}, the value of the previous key '#{$prev-key}' !";
+    }
+    $prev-key: $key;
+    $prev-num: $num;
+  }
+}
+
+// Starts at zero
+// Another grid mixin that ensures the min-width of the lowest breakpoint starts at 0.
+@mixin _assert-starts-at-zero($map) {
+  $values: map-values($map);
+  $first-value: nth($values, 1);
+  @if $first-value != 0 {
+    @warn "First breakpoint in `$grid-breakpoints` must start at 0, but starts at #{$first-value}.";
+  }
+}
+/*
+// Replace `$search` with `$replace` in `$string`
+// Used on our SVG icon backgrounds for custom forms.
+//
+// @author Hugo Giraudel
+// @param {String} $string - Initial string
+// @param {String} $search - Substring to replace
+// @param {String} $replace ('') - New value
+// @return {String} - Updated string
+@function str-replace($string, $search, $replace: "") {
+  $index: str-index($string, $search);
+
+  @if $index {
+    @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
+  }
+
+  @return $string;
+}
+
+// Color contrast
+@function color-yiq($color) {
+  $r: red($color);
+  $g: green($color);
+  $b: blue($color);
+
+  $yiq: (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
+
+  @if ($yiq >= $yiq-contrasted-threshold) {
+    @return $yiq-text-dark;
+  } @else {
+    @return $yiq-text-light;
+  }
+}
+
+// Retrieve color Sass maps
+@function color($key: "blue") {
+  @return map-get($colors, $key);
+}
+
+@function theme-color($key: "primary") {
+  @return map-get($theme-colors, $key);
+}
+
+@function gray($key: "100") {
+  @return map-get($grays, $key);
+}
+
+// Request a theme color level
+@function theme-color-level($color-name: "primary", $level: 0) {
+  $color: theme-color($color-name);
+  $color-base: if($level > 0, $black, $white);
+  $level: abs($level);
+
+  @return mix($color-base, $color, $level * $theme-color-interval);
+}
+*/

+ 52 - 52
src/client/styles/bootstrap4/_grid.scss

@@ -1,52 +1,52 @@
-// Container widths
-//
-// Set the container width, and override it for fixed navbars in media queries.
-
-@if $enable-grid-classes {
-  .container {
-    @include make-container();
-    @include make-container-max-widths();
-  }
-}
-
-// Fluid container
-//
-// Utilizes the mixin meant for fixed width containers, but with 100% width for
-// fluid, full width layouts.
-
-@if $enable-grid-classes {
-  .container-fluid {
-    @include make-container();
-  }
-}
-
-// Row
-//
-// Rows contain and clear the floats of your columns.
-
-@if $enable-grid-classes {
-  .row {
-    @include make-row();
-  }
-
-  // Remove the negative margin from default .row, then the horizontal padding
-  // from all immediate children columns (to prevent runaway style inheritance).
-  .no-gutters {
-    margin-right: 0;
-    margin-left: 0;
-
-    > .col,
-    > [class*="col-"] {
-      padding-right: 0;
-      padding-left: 0;
-    }
-  }
-}
-
-// Columns
-//
-// Common styles for small and large grid columns
-
-@if $enable-grid-classes {
-  @include make-grid-columns();
-}
+// Container widths
+//
+// Set the container width, and override it for fixed navbars in media queries.
+
+@if $enable-grid-classes {
+  .container {
+    @include make-container();
+    @include make-container-max-widths();
+  }
+}
+
+// Fluid container
+//
+// Utilizes the mixin meant for fixed width containers, but with 100% width for
+// fluid, full width layouts.
+
+@if $enable-grid-classes {
+  .container-fluid {
+    @include make-container();
+  }
+}
+
+// Row
+//
+// Rows contain and clear the floats of your columns.
+
+@if $enable-grid-classes {
+  .row {
+    @include make-row();
+  }
+
+  // Remove the negative margin from default .row, then the horizontal padding
+  // from all immediate children columns (to prevent runaway style inheritance).
+  .no-gutters {
+    margin-right: 0;
+    margin-left: 0;
+
+    > .col,
+    > [class*="col-"] {
+      padding-right: 0;
+      padding-left: 0;
+    }
+  }
+}
+
+// Columns
+//
+// Common styles for small and large grid columns
+
+@if $enable-grid-classes {
+  @include make-grid-columns();
+}

+ 42 - 42
src/client/styles/bootstrap4/_images.scss

@@ -1,42 +1,42 @@
-// Responsive images (ensure images don't scale beyond their parents)
-//
-// This is purposefully opt-in via an explicit class rather than being the default for all `<img>`s.
-// We previously tried the "images are responsive by default" approach in Bootstrap v2,
-// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)
-// which weren't expecting the images within themselves to be involuntarily resized.
-// See also https://github.com/twbs/bootstrap/issues/18178
-.img-fluid {
-  @include img-fluid;
-}
-
-
-// Image thumbnails
-.img-thumbnail {
-  padding: $thumbnail-padding;
-  background-color: $thumbnail-bg;
-  border: $thumbnail-border-width solid $thumbnail-border-color;
-  @include border-radius($thumbnail-border-radius);
-  @include box-shadow($thumbnail-box-shadow);
-
-  // Keep them at most 100% wide
-  @include img-fluid;
-}
-
-//
-// Figures
-//
-
-.figure {
-  // Ensures the caption's text aligns with the image.
-  display: inline-block;
-}
-
-.figure-img {
-  margin-bottom: ($spacer / 2);
-  line-height: 1;
-}
-
-.figure-caption {
-  font-size: $figure-caption-font-size;
-  color: $figure-caption-color;
-}
+// Responsive images (ensure images don't scale beyond their parents)
+//
+// This is purposefully opt-in via an explicit class rather than being the default for all `<img>`s.
+// We previously tried the "images are responsive by default" approach in Bootstrap v2,
+// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)
+// which weren't expecting the images within themselves to be involuntarily resized.
+// See also https://github.com/twbs/bootstrap/issues/18178
+.img-fluid {
+  @include img-fluid;
+}
+
+
+// Image thumbnails
+.img-thumbnail {
+  padding: $thumbnail-padding;
+  background-color: $thumbnail-bg;
+  border: $thumbnail-border-width solid $thumbnail-border-color;
+  @include border-radius($thumbnail-border-radius);
+  @include box-shadow($thumbnail-box-shadow);
+
+  // Keep them at most 100% wide
+  @include img-fluid;
+}
+
+//
+// Figures
+//
+
+.figure {
+  // Ensures the caption's text aligns with the image.
+  display: inline-block;
+}
+
+.figure-img {
+  margin-bottom: ($spacer / 2);
+  line-height: 1;
+}
+
+.figure-caption {
+  font-size: $figure-caption-font-size;
+  color: $figure-caption-color;
+}

+ 173 - 159
src/client/styles/bootstrap4/_input-group.scss

@@ -1,159 +1,173 @@
-// stylelint-disable selector-no-qualifying-type
-
-//
-// Base styles
-//
-
-.input-group {
-  position: relative;
-  display: flex;
-  flex-wrap: wrap; // For form validation feedback
-  align-items: stretch;
-  width: 100%;
-
-  > .form-control,
-  > .custom-select,
-  > .custom-file {
-    position: relative; // For focus state's z-index
-    flex: 1 1 auto;
-    // Add width 1% and flex-basis auto to ensure that button will not wrap out
-    // the column. Applies to IE Edge+ and Firefox. Chrome does not require this.
-    width: 1%;
-    margin-bottom: 0;
-
-    // Bring the "active" form control to the top of surrounding elements
-    &:focus {
-      z-index: 3;
-    }
-
-    + .form-control,
-    + .custom-select,
-    + .custom-file {
-      margin-left: -$input-border-width;
-    }
-  }
-
-  > .form-control,
-  > .custom-select {
-    &:not(:last-child) { @include border-right-radius(0); }
-    &:not(:first-child) { @include border-left-radius(0); }
-  }
-
-  // Custom file inputs have more complex markup, thus requiring different
-  // border-radius overrides.
-  > .custom-file {
-    display: flex;
-    align-items: center;
-
-    &:not(:last-child) .custom-file-label,
-    &:not(:last-child) .custom-file-label::before { @include border-right-radius(0); }
-    &:not(:first-child) .custom-file-label,
-    &:not(:first-child) .custom-file-label::before { @include border-left-radius(0); }
-  }
-}
-
-
-// Prepend and append
-//
-// While it requires one extra layer of HTML for each, dedicated prepend and
-// append elements allow us to 1) be less clever, 2) simplify our selectors, and
-// 3) support HTML5 form validation.
-
-.input-group-prepend,
-.input-group-append {
-  display: flex;
-
-  // Ensure buttons are always above inputs for more visually pleasing borders.
-  // This isn't needed for `.input-group-text` since it shares the same border-color
-  // as our inputs.
-  .btn {
-    position: relative;
-    z-index: 2;
-  }
-
-  .btn + .btn,
-  .btn + .input-group-text,
-  .input-group-text + .input-group-text,
-  .input-group-text + .btn {
-    margin-left: -$input-border-width;
-  }
-}
-
-.input-group-prepend { margin-right: -$input-border-width; }
-.input-group-append { margin-left: -$input-border-width; }
-
-
-// Textual addons
-//
-// Serves as a catch-all element for any text or radio/checkbox input you wish
-// to prepend or append to an input.
-
-.input-group-text {
-  display: flex;
-  align-items: center;
-  padding: $input-padding-y $input-padding-x;
-  margin-bottom: 0; // Allow use of <label> elements by overriding our default margin-bottom
-  font-size: $font-size-base; // Match inputs
-  font-weight: $font-weight-normal;
-  line-height: $input-line-height;
-  color: $input-group-addon-color;
-  text-align: center;
-  white-space: nowrap;
-  background-color: $input-group-addon-bg;
-  border: $input-border-width solid $input-group-addon-border-color;
-  @include border-radius($input-border-radius);
-
-  // Nuke default margins from checkboxes and radios to vertically center within.
-  input[type="radio"],
-  input[type="checkbox"] {
-    margin-top: 0;
-  }
-}
-
-
-// Sizing
-//
-// Remix the default form control sizing classes into new ones for easier
-// manipulation.
-
-.input-group-lg > .form-control,
-.input-group-lg > .input-group-prepend > .input-group-text,
-.input-group-lg > .input-group-append > .input-group-text,
-.input-group-lg > .input-group-prepend > .btn,
-.input-group-lg > .input-group-append > .btn {
-  @extend .form-control-lg;
-}
-
-.input-group-sm > .form-control,
-.input-group-sm > .input-group-prepend > .input-group-text,
-.input-group-sm > .input-group-append > .input-group-text,
-.input-group-sm > .input-group-prepend > .btn,
-.input-group-sm > .input-group-append > .btn {
-  @extend .form-control-sm;
-}
-
-
-// Prepend and append rounded corners
-//
-// These rulesets must come after the sizing ones to properly override sm and lg
-// border-radius values when extending. They're more specific than we'd like
-// with the `.input-group >` part, but without it, we cannot override the sizing.
-
-
-.input-group > .input-group-prepend > .btn,
-.input-group > .input-group-prepend > .input-group-text,
-.input-group > .input-group-append:not(:last-child) > .btn,
-.input-group > .input-group-append:not(:last-child) > .input-group-text,
-.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle),
-.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) {
-  @include border-right-radius(0);
-}
-
-.input-group > .input-group-append > .btn,
-.input-group > .input-group-append > .input-group-text,
-.input-group > .input-group-prepend:not(:first-child) > .btn,
-.input-group > .input-group-prepend:not(:first-child) > .input-group-text,
-.input-group > .input-group-prepend:first-child > .btn:not(:first-child),
-.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) {
-  @include border-left-radius(0);
-}
+// stylelint-disable selector-no-qualifying-type
+
+//
+// Base styles
+//
+
+.input-group {
+  position: relative;
+  display: flex;
+  flex-wrap: wrap; // For form validation feedback
+  align-items: stretch;
+  width: 100%;
+
+  > .form-control,
+  > .custom-select,
+  > .custom-file {
+    position: relative; // For focus state's z-index
+    flex: 1 1 auto;
+    // Add width 1% and flex-basis auto to ensure that button will not wrap out
+    // the column. Applies to IE Edge+ and Firefox. Chrome does not require this.
+    width: 1%;
+    margin-bottom: 0;
+
+    + .form-control,
+    + .custom-select,
+    + .custom-file {
+      margin-left: -$input-border-width;
+    }
+  }
+
+  // Bring the "active" form control to the top of surrounding elements
+  > .form-control:focus,
+  > .custom-select:focus,
+  > .custom-file .custom-file-input:focus ~ .custom-file-label {
+    z-index: 3;
+  }
+
+  // Bring the custom file input above the label
+  > .custom-file .custom-file-input:focus {
+    z-index: 4;
+  }
+
+  > .form-control,
+  > .custom-select {
+    &:not(:last-child) { @include border-right-radius(0); }
+    &:not(:first-child) { @include border-left-radius(0); }
+  }
+
+  // Custom file inputs have more complex markup, thus requiring different
+  // border-radius overrides.
+  > .custom-file {
+    display: flex;
+    align-items: center;
+
+    &:not(:last-child) .custom-file-label,
+    &:not(:last-child) .custom-file-label::after { @include border-right-radius(0); }
+    &:not(:first-child) .custom-file-label { @include border-left-radius(0); }
+  }
+}
+
+
+// Prepend and append
+//
+// While it requires one extra layer of HTML for each, dedicated prepend and
+// append elements allow us to 1) be less clever, 2) simplify our selectors, and
+// 3) support HTML5 form validation.
+
+.input-group-prepend,
+.input-group-append {
+  display: flex;
+
+  // Ensure buttons are always above inputs for more visually pleasing borders.
+  // This isn't needed for `.input-group-text` since it shares the same border-color
+  // as our inputs.
+  .btn {
+    position: relative;
+    z-index: 2;
+  }
+
+  .btn + .btn,
+  .btn + .input-group-text,
+  .input-group-text + .input-group-text,
+  .input-group-text + .btn {
+    margin-left: -$input-border-width;
+  }
+}
+
+.input-group-prepend { margin-right: -$input-border-width; }
+.input-group-append { margin-left: -$input-border-width; }
+
+
+// Textual addons
+//
+// Serves as a catch-all element for any text or radio/checkbox input you wish
+// to prepend or append to an input.
+
+.input-group-text {
+  display: flex;
+  align-items: center;
+  padding: $input-padding-y $input-padding-x;
+  margin-bottom: 0; // Allow use of <label> elements by overriding our default margin-bottom
+  font-size: $font-size-base; // Match inputs
+  font-weight: $font-weight-normal;
+  line-height: $input-line-height;
+  color: $input-group-addon-color;
+  text-align: center;
+  white-space: nowrap;
+  background-color: $input-group-addon-bg;
+  border: $input-border-width solid $input-group-addon-border-color;
+  @include border-radius($input-border-radius);
+
+  // Nuke default margins from checkboxes and radios to vertically center within.
+  input[type="radio"],
+  input[type="checkbox"] {
+    margin-top: 0;
+  }
+}
+
+
+// Sizing
+//
+// Remix the default form control sizing classes into new ones for easier
+// manipulation.
+
+.input-group-lg > .form-control,
+.input-group-lg > .input-group-prepend > .input-group-text,
+.input-group-lg > .input-group-append > .input-group-text,
+.input-group-lg > .input-group-prepend > .btn,
+.input-group-lg > .input-group-append > .btn {
+  height: $input-height-lg;
+  padding: $input-padding-y-lg $input-padding-x-lg;
+  font-size: $font-size-lg;
+  line-height: $input-line-height-lg;
+  @include border-radius($input-border-radius-lg);
+}
+
+.input-group-sm > .form-control,
+.input-group-sm > .input-group-prepend > .input-group-text,
+.input-group-sm > .input-group-append > .input-group-text,
+.input-group-sm > .input-group-prepend > .btn,
+.input-group-sm > .input-group-append > .btn {
+  height: $input-height-sm;
+  padding: $input-padding-y-sm $input-padding-x-sm;
+  font-size: $font-size-sm;
+  line-height: $input-line-height-sm;
+  @include border-radius($input-border-radius-sm);
+}
+
+
+// Prepend and append rounded corners
+//
+// These rulesets must come after the sizing ones to properly override sm and lg
+// border-radius values when extending. They're more specific than we'd like
+// with the `.input-group >` part, but without it, we cannot override the sizing.
+
+
+.input-group > .input-group-prepend > .btn,
+.input-group > .input-group-prepend > .input-group-text,
+.input-group > .input-group-append:not(:last-child) > .btn,
+.input-group > .input-group-append:not(:last-child) > .input-group-text,
+.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle),
+.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) {
+  @include border-right-radius(0);
+}
+
+.input-group > .input-group-append > .btn,
+.input-group > .input-group-append > .input-group-text,
+.input-group > .input-group-prepend:not(:first-child) > .btn,
+.input-group > .input-group-prepend:not(:first-child) > .input-group-text,
+.input-group > .input-group-prepend:first-child > .btn:not(:first-child),
+.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) {
+  @include border-left-radius(0);
+}

+ 16 - 16
src/client/styles/bootstrap4/_jumbotron.scss

@@ -1,16 +1,16 @@
-.jumbotron {
-  padding: $jumbotron-padding ($jumbotron-padding / 2);
-  margin-bottom: $jumbotron-padding;
-  background-color: $jumbotron-bg;
-  @include border-radius($border-radius-lg);
-
-  @include media-breakpoint-up(sm) {
-    padding: ($jumbotron-padding * 2) $jumbotron-padding;
-  }
-}
-
-.jumbotron-fluid {
-  padding-right: 0;
-  padding-left: 0;
-  @include border-radius(0);
-}
+.jumbotron {
+  padding: $jumbotron-padding ($jumbotron-padding / 2);
+  margin-bottom: $jumbotron-padding;
+  background-color: $jumbotron-bg;
+  @include border-radius($border-radius-lg);
+
+  @include media-breakpoint-up(sm) {
+    padding: ($jumbotron-padding * 2) $jumbotron-padding;
+  }
+}
+
+.jumbotron-fluid {
+  padding-right: 0;
+  padding-left: 0;
+  @include border-radius(0);
+}

+ 115 - 115
src/client/styles/bootstrap4/_list-group.scss

@@ -1,115 +1,115 @@
-// Base class
-//
-// Easily usable on <ul>, <ol>, or <div>.
-
-.list-group {
-  display: flex;
-  flex-direction: column;
-
-  // No need to set list-style: none; since .list-group-item is block level
-  padding-left: 0; // reset padding because ul and ol
-  margin-bottom: 0;
-}
-
-
-// Interactive list items
-//
-// Use anchor or button elements instead of `li`s or `div`s to create interactive
-// list items. Includes an extra `.active` modifier class for selected items.
-
-.list-group-item-action {
-  width: 100%; // For `<button>`s (anchors become 100% by default though)
-  color: $list-group-action-color;
-  text-align: inherit; // For `<button>`s (anchors inherit)
-
-  // Hover state
-  @include hover-focus {
-    color: $list-group-action-hover-color;
-    text-decoration: none;
-    background-color: $list-group-hover-bg;
-  }
-
-  &:active {
-    color: $list-group-action-active-color;
-    background-color: $list-group-action-active-bg;
-  }
-}
-
-
-// Individual list items
-//
-// Use on `li`s or `div`s within the `.list-group` parent.
-
-.list-group-item {
-  position: relative;
-  display: block;
-  padding: $list-group-item-padding-y $list-group-item-padding-x;
-  // Place the border on the list items and negative margin up for better styling
-  margin-bottom: -$list-group-border-width;
-  background-color: $list-group-bg;
-  border: $list-group-border-width solid $list-group-border-color;
-
-  &:first-child {
-    @include border-top-radius($list-group-border-radius);
-  }
-
-  &:last-child {
-    margin-bottom: 0;
-    @include border-bottom-radius($list-group-border-radius);
-  }
-
-  @include hover-focus {
-    z-index: 1; // Place hover/active items above their siblings for proper border styling
-    text-decoration: none;
-  }
-
-  &.disabled,
-  &:disabled {
-    color: $list-group-disabled-color;
-    background-color: $list-group-disabled-bg;
-  }
-
-  // Include both here for `<a>`s and `<button>`s
-  &.active {
-    z-index: 2; // Place active items above their siblings for proper border styling
-    color: $list-group-active-color;
-    background-color: $list-group-active-bg;
-    border-color: $list-group-active-border-color;
-  }
-}
-
-
-// Flush list items
-//
-// Remove borders and border-radius to keep list group items edge-to-edge. Most
-// useful within other components (e.g., cards).
-
-.list-group-flush {
-  .list-group-item {
-    border-right: 0;
-    border-left: 0;
-    @include border-radius(0);
-  }
-
-  &:first-child {
-    .list-group-item:first-child {
-      border-top: 0;
-    }
-  }
-
-  &:last-child {
-    .list-group-item:last-child {
-      border-bottom: 0;
-    }
-  }
-}
-
-
-// Contextual variants
-//
-// Add modifier classes to change text and background color on individual items.
-// Organizationally, this must come after the `:hover` states.
-
-@each $color, $value in $theme-colors {
-  @include list-group-item-variant($color, theme-color-level($color, -9), theme-color-level($color, 6));
-}
+// Base class
+//
+// Easily usable on <ul>, <ol>, or <div>.
+
+.list-group {
+  display: flex;
+  flex-direction: column;
+
+  // No need to set list-style: none; since .list-group-item is block level
+  padding-left: 0; // reset padding because ul and ol
+  margin-bottom: 0;
+}
+
+
+// Interactive list items
+//
+// Use anchor or button elements instead of `li`s or `div`s to create interactive
+// list items. Includes an extra `.active` modifier class for selected items.
+
+.list-group-item-action {
+  width: 100%; // For `<button>`s (anchors become 100% by default though)
+  color: $list-group-action-color;
+  text-align: inherit; // For `<button>`s (anchors inherit)
+
+  // Hover state
+  @include hover-focus {
+    color: $list-group-action-hover-color;
+    text-decoration: none;
+    background-color: $list-group-hover-bg;
+  }
+
+  &:active {
+    color: $list-group-action-active-color;
+    background-color: $list-group-action-active-bg;
+  }
+}
+
+
+// Individual list items
+//
+// Use on `li`s or `div`s within the `.list-group` parent.
+
+.list-group-item {
+  position: relative;
+  display: block;
+  padding: $list-group-item-padding-y $list-group-item-padding-x;
+  // Place the border on the list items and negative margin up for better styling
+  margin-bottom: -$list-group-border-width;
+  background-color: $list-group-bg;
+  border: $list-group-border-width solid $list-group-border-color;
+
+  &:first-child {
+    @include border-top-radius($list-group-border-radius);
+  }
+
+  &:last-child {
+    margin-bottom: 0;
+    @include border-bottom-radius($list-group-border-radius);
+  }
+
+  @include hover-focus {
+    z-index: 1; // Place hover/active items above their siblings for proper border styling
+    text-decoration: none;
+  }
+
+  &.disabled,
+  &:disabled {
+    color: $list-group-disabled-color;
+    background-color: $list-group-disabled-bg;
+  }
+
+  // Include both here for `<a>`s and `<button>`s
+  &.active {
+    z-index: 2; // Place active items above their siblings for proper border styling
+    color: $list-group-active-color;
+    background-color: $list-group-active-bg;
+    border-color: $list-group-active-border-color;
+  }
+}
+
+
+// Flush list items
+//
+// Remove borders and border-radius to keep list group items edge-to-edge. Most
+// useful within other components (e.g., cards).
+
+.list-group-flush {
+  .list-group-item {
+    border-right: 0;
+    border-left: 0;
+    @include border-radius(0);
+  }
+
+  &:first-child {
+    .list-group-item:first-child {
+      border-top: 0;
+    }
+  }
+
+  &:last-child {
+    .list-group-item:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
+
+// Contextual variants
+//
+// Add modifier classes to change text and background color on individual items.
+// Organizationally, this must come after the `:hover` states.
+
+@each $color, $value in $theme-colors {
+  @include list-group-item-variant($color, theme-color-level($color, -9), theme-color-level($color, 6));
+}

+ 8 - 8
src/client/styles/bootstrap4/_media.scss

@@ -1,8 +1,8 @@
-.media {
-  display: flex;
-  align-items: flex-start;
-}
-
-.media-body {
-  flex: 1;
-}
+.media {
+  display: flex;
+  align-items: flex-start;
+}
+
+.media-body {
+  flex: 1;
+}

+ 3 - 4
src/client/styles/bootstrap4/_mixins.scss

@@ -3,7 +3,7 @@
 // Used in conjunction with global variables to enable certain theme features.
 
 // Utilities
-@import "mixins/breakpoints";
+@import 'mixins/breakpoints';
 // @import "mixins/hover";
 // @import "mixins/image";
 // @import "mixins/badge";
@@ -12,8 +12,8 @@
 // @import "mixins/size";
 // @import "mixins/reset-text";
 // @import "mixins/text-emphasis";
-@import "mixins/text-hide";
-@import "mixins/text-truncate";
+@import 'mixins/text-hide';
+@import 'mixins/text-truncate';
 // @import "mixins/visibility";
 
 // // Components
@@ -36,7 +36,6 @@
 
 // // Layout
 // @import "mixins/clearfix";
-// @import "mixins/navbar-align";
 // @import "mixins/grid-framework";
 // @import "mixins/grid";
 // @import "mixins/float";

+ 180 - 168
src/client/styles/bootstrap4/_modal.scss

@@ -1,168 +1,180 @@
-// .modal-open      - body class for killing the scroll
-// .modal           - container to scroll within
-// .modal-dialog    - positioning shell for the actual modal
-// .modal-content   - actual modal w/ bg and corners and stuff
-
-
-// Kill the scroll on the body
-.modal-open {
-  overflow: hidden;
-}
-
-// Container that the modal scrolls within
-.modal {
-  position: fixed;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  z-index: $zindex-modal;
-  display: none;
-  overflow: hidden;
-  // Prevent Chrome on Windows from adding a focus outline. For details, see
-  // https://github.com/twbs/bootstrap/pull/10951.
-  outline: 0;
-  // We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a
-  // gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342
-  // See also https://github.com/twbs/bootstrap/issues/17695
-
-  .modal-open & {
-    overflow-x: hidden;
-    overflow-y: auto;
-  }
-}
-
-// Shell div to position the modal with bottom padding
-.modal-dialog {
-  position: relative;
-  width: auto;
-  margin: $modal-dialog-margin;
-  // allow clicks to pass through for custom click handling to close modal
-  pointer-events: none;
-
-  // When fading in the modal, animate it to slide down
-  .modal.fade & {
-    @include transition($modal-transition);
-    transform: translate(0, -25%);
-  }
-  .modal.show & {
-    transform: translate(0, 0);
-  }
-}
-
-.modal-dialog-centered {
-  display: flex;
-  align-items: center;
-  min-height: calc(100% - (#{$modal-dialog-margin} * 2));
-}
-
-// Actual modal
-.modal-content {
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  width: 100%; // Ensure `.modal-content` extends the full width of the parent `.modal-dialog`
-  // counteract the pointer-events: none; in the .modal-dialog
-  pointer-events: auto;
-  background-color: $modal-content-bg;
-  background-clip: padding-box;
-  border: $modal-content-border-width solid $modal-content-border-color;
-  @include border-radius($border-radius-lg);
-  @include box-shadow($modal-content-box-shadow-xs);
-  // Remove focus outline from opened modal
-  outline: 0;
-}
-
-// Modal background
-.modal-backdrop {
-  position: fixed;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  z-index: $zindex-modal-backdrop;
-  background-color: $modal-backdrop-bg;
-
-  // Fade for backdrop
-  &.fade { opacity: 0; }
-  &.show { opacity: $modal-backdrop-opacity; }
-}
-
-// Modal header
-// Top section of the modal w/ title and dismiss
-.modal-header {
-  display: flex;
-  align-items: flex-start; // so the close btn always stays on the upper right corner
-  justify-content: space-between; // Put modal header elements (title and dismiss) on opposite ends
-  padding: $modal-header-padding;
-  border-bottom: $modal-header-border-width solid $modal-header-border-color;
-  @include border-top-radius($border-radius-lg);
-
-  .close {
-    padding: $modal-header-padding;
-    // auto on the left force icon to the right even when there is no .modal-title
-    margin: (-$modal-header-padding) (-$modal-header-padding) (-$modal-header-padding) auto;
-  }
-}
-
-// Title text within header
-.modal-title {
-  margin-bottom: 0;
-  line-height: $modal-title-line-height;
-}
-
-// Modal body
-// Where all modal content resides (sibling of .modal-header and .modal-footer)
-.modal-body {
-  position: relative;
-  // Enable `flex-grow: 1` so that the body take up as much space as possible
-  // when should there be a fixed height on `.modal-dialog`.
-  flex: 1 1 auto;
-  padding: $modal-inner-padding;
-}
-
-// Footer (for actions)
-.modal-footer {
-  display: flex;
-  align-items: center; // vertically center
-  justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items
-  padding: $modal-inner-padding;
-  border-top: $modal-footer-border-width solid $modal-footer-border-color;
-
-  // Easily place margin between footer elements
-  > :not(:first-child) { margin-left: .25rem; }
-  > :not(:last-child) { margin-right: .25rem; }
-}
-
-// Measure scrollbar width for padding body during modal show/hide
-.modal-scrollbar-measure {
-  position: absolute;
-  top: -9999px;
-  width: 50px;
-  height: 50px;
-  overflow: scroll;
-}
-
-// Scale up the modal
-@include media-breakpoint-up(sm) {
-  // Automatically set modal's width for larger viewports
-  .modal-dialog {
-    max-width: $modal-md;
-    margin: $modal-dialog-margin-y-sm-up auto;
-  }
-
-  .modal-dialog-centered {
-    min-height: calc(100% - (#{$modal-dialog-margin-y-sm-up} * 2));
-  }
-
-  .modal-content {
-    @include box-shadow($modal-content-box-shadow-sm-up);
-  }
-
-  .modal-sm { max-width: $modal-sm; }
-
-}
-
-@include media-breakpoint-up(lg) {
-  .modal-lg { max-width: $modal-lg; }
-}
+// .modal-open      - body class for killing the scroll
+// .modal           - container to scroll within
+// .modal-dialog    - positioning shell for the actual modal
+// .modal-content   - actual modal w/ bg and corners and stuff
+
+
+.modal-open {
+  // Kill the scroll on the body
+  overflow: hidden;
+
+  .modal {
+    overflow-x: hidden;
+    overflow-y: auto;
+  }
+}
+
+// Container that the modal scrolls within
+.modal {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: $zindex-modal;
+  display: none;
+  overflow: hidden;
+  // Prevent Chrome on Windows from adding a focus outline. For details, see
+  // https://github.com/twbs/bootstrap/pull/10951.
+  outline: 0;
+  // We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a
+  // gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342
+  // See also https://github.com/twbs/bootstrap/issues/17695
+}
+
+// Shell div to position the modal with bottom padding
+.modal-dialog {
+  position: relative;
+  width: auto;
+  margin: $modal-dialog-margin;
+  // allow clicks to pass through for custom click handling to close modal
+  pointer-events: none;
+
+  // When fading in the modal, animate it to slide down
+  .modal.fade & {
+    @include transition($modal-transition);
+    transform: translate(0, -25%);
+  }
+  .modal.show & {
+    transform: translate(0, 0);
+  }
+}
+
+.modal-dialog-centered {
+  display: flex;
+  align-items: center;
+  min-height: calc(100% - (#{$modal-dialog-margin} * 2));
+
+  // Ensure `modal-dialog-centered` extends the full height of the view (IE10/11)
+  &::before {
+    display: block; // IE10
+    height: calc(100vh - (#{$modal-dialog-margin} * 2));
+    content: "";
+  }
+}
+
+// Actual modal
+.modal-content {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  width: 100%; // Ensure `.modal-content` extends the full width of the parent `.modal-dialog`
+  // counteract the pointer-events: none; in the .modal-dialog
+  pointer-events: auto;
+  background-color: $modal-content-bg;
+  background-clip: padding-box;
+  border: $modal-content-border-width solid $modal-content-border-color;
+  @include border-radius($modal-content-border-radius);
+  @include box-shadow($modal-content-box-shadow-xs);
+  // Remove focus outline from opened modal
+  outline: 0;
+}
+
+// Modal background
+.modal-backdrop {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: $zindex-modal-backdrop;
+  background-color: $modal-backdrop-bg;
+
+  // Fade for backdrop
+  &.fade { opacity: 0; }
+  &.show { opacity: $modal-backdrop-opacity; }
+}
+
+// Modal header
+// Top section of the modal w/ title and dismiss
+.modal-header {
+  display: flex;
+  align-items: flex-start; // so the close btn always stays on the upper right corner
+  justify-content: space-between; // Put modal header elements (title and dismiss) on opposite ends
+  padding: $modal-header-padding;
+  border-bottom: $modal-header-border-width solid $modal-header-border-color;
+  @include border-top-radius($modal-content-border-radius);
+
+  .close {
+    padding: $modal-header-padding;
+    // auto on the left force icon to the right even when there is no .modal-title
+    margin: (-$modal-header-padding) (-$modal-header-padding) (-$modal-header-padding) auto;
+  }
+}
+
+// Title text within header
+.modal-title {
+  margin-bottom: 0;
+  line-height: $modal-title-line-height;
+}
+
+// Modal body
+// Where all modal content resides (sibling of .modal-header and .modal-footer)
+.modal-body {
+  position: relative;
+  // Enable `flex-grow: 1` so that the body take up as much space as possible
+  // when should there be a fixed height on `.modal-dialog`.
+  flex: 1 1 auto;
+  padding: $modal-inner-padding;
+}
+
+// Footer (for actions)
+.modal-footer {
+  display: flex;
+  align-items: center; // vertically center
+  justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items
+  padding: $modal-inner-padding;
+  border-top: $modal-footer-border-width solid $modal-footer-border-color;
+
+  // Easily place margin between footer elements
+  > :not(:first-child) { margin-left: .25rem; }
+  > :not(:last-child) { margin-right: .25rem; }
+}
+
+// Measure scrollbar width for padding body during modal show/hide
+.modal-scrollbar-measure {
+  position: absolute;
+  top: -9999px;
+  width: 50px;
+  height: 50px;
+  overflow: scroll;
+}
+
+// Scale up the modal
+@include media-breakpoint-up(sm) {
+  // Automatically set modal's width for larger viewports
+  .modal-dialog {
+    max-width: $modal-md;
+    margin: $modal-dialog-margin-y-sm-up auto;
+  }
+
+  .modal-dialog-centered {
+    min-height: calc(100% - (#{$modal-dialog-margin-y-sm-up} * 2));
+
+    &::before {
+      height: calc(100vh - (#{$modal-dialog-margin-y-sm-up} * 2));
+    }
+
+  }
+
+  .modal-content {
+    @include box-shadow($modal-content-box-shadow-sm-up);
+  }
+
+  .modal-sm { max-width: $modal-sm; }
+
+}
+
+@include media-breakpoint-up(lg) {
+  .modal-lg { max-width: $modal-lg; }
+}

+ 118 - 118
src/client/styles/bootstrap4/_nav.scss

@@ -1,118 +1,118 @@
-// Base class
-//
-// Kickstart any navigation component with a set of style resets. Works with
-// `<nav>`s or `<ul>`s.
-
-.nav {
-  display: flex;
-  flex-wrap: wrap;
-  padding-left: 0;
-  margin-bottom: 0;
-  list-style: none;
-}
-
-.nav-link {
-  display: block;
-  padding: $nav-link-padding-y $nav-link-padding-x;
-
-  @include hover-focus {
-    text-decoration: none;
-  }
-
-  // Disabled state lightens text
-  &.disabled {
-    color: $nav-link-disabled-color;
-  }
-}
-
-//
-// Tabs
-//
-
-.nav-tabs {
-  border-bottom: $nav-tabs-border-width solid $nav-tabs-border-color;
-
-  .nav-item {
-    margin-bottom: -$nav-tabs-border-width;
-  }
-
-  .nav-link {
-    border: $nav-tabs-border-width solid transparent;
-    @include border-top-radius($nav-tabs-border-radius);
-
-    @include hover-focus {
-      border-color: $nav-tabs-link-hover-border-color;
-    }
-
-    &.disabled {
-      color: $nav-link-disabled-color;
-      background-color: transparent;
-      border-color: transparent;
-    }
-  }
-
-  .nav-link.active,
-  .nav-item.show .nav-link {
-    color: $nav-tabs-link-active-color;
-    background-color: $nav-tabs-link-active-bg;
-    border-color: $nav-tabs-link-active-border-color;
-  }
-
-  .dropdown-menu {
-    // Make dropdown border overlap tab border
-    margin-top: -$nav-tabs-border-width;
-    // Remove the top rounded corners here since there is a hard edge above the menu
-    @include border-top-radius(0);
-  }
-}
-
-
-//
-// Pills
-//
-
-.nav-pills {
-  .nav-link {
-    @include border-radius($nav-pills-border-radius);
-  }
-
-  .nav-link.active,
-  .show > .nav-link {
-    color: $nav-pills-link-active-color;
-    background-color: $nav-pills-link-active-bg;
-  }
-}
-
-
-//
-// Justified variants
-//
-
-.nav-fill {
-  .nav-item {
-    flex: 1 1 auto;
-    text-align: center;
-  }
-}
-
-.nav-justified {
-  .nav-item {
-    flex-basis: 0;
-    flex-grow: 1;
-    text-align: center;
-  }
-}
-
-
-// Tabbable tabs
-//
-// Hide tabbable panes to start, show them when `.active`
-
-.tab-content {
-  > .tab-pane {
-    display: none;
-  }
-  > .active {
-    display: block;
-  }
-}
+// Base class
+//
+// Kickstart any navigation component with a set of style resets. Works with
+// `<nav>`s or `<ul>`s.
+
+.nav {
+  display: flex;
+  flex-wrap: wrap;
+  padding-left: 0;
+  margin-bottom: 0;
+  list-style: none;
+}
+
+.nav-link {
+  display: block;
+  padding: $nav-link-padding-y $nav-link-padding-x;
+
+  @include hover-focus {
+    text-decoration: none;
+  }
+
+  // Disabled state lightens text
+  &.disabled {
+    color: $nav-link-disabled-color;
+  }
+}
+
+//
+// Tabs
+//
+
+.nav-tabs {
+  border-bottom: $nav-tabs-border-width solid $nav-tabs-border-color;
+
+  .nav-item {
+    margin-bottom: -$nav-tabs-border-width;
+  }
+
+  .nav-link {
+    border: $nav-tabs-border-width solid transparent;
+    @include border-top-radius($nav-tabs-border-radius);
+
+    @include hover-focus {
+      border-color: $nav-tabs-link-hover-border-color;
+    }
+
+    &.disabled {
+      color: $nav-link-disabled-color;
+      background-color: transparent;
+      border-color: transparent;
+    }
+  }
+
+  .nav-link.active,
+  .nav-item.show .nav-link {
+    color: $nav-tabs-link-active-color;
+    background-color: $nav-tabs-link-active-bg;
+    border-color: $nav-tabs-link-active-border-color;
+  }
+
+  .dropdown-menu {
+    // Make dropdown border overlap tab border
+    margin-top: -$nav-tabs-border-width;
+    // Remove the top rounded corners here since there is a hard edge above the menu
+    @include border-top-radius(0);
+  }
+}
+
+
+//
+// Pills
+//
+
+.nav-pills {
+  .nav-link {
+    @include border-radius($nav-pills-border-radius);
+  }
+
+  .nav-link.active,
+  .show > .nav-link {
+    color: $nav-pills-link-active-color;
+    background-color: $nav-pills-link-active-bg;
+  }
+}
+
+
+//
+// Justified variants
+//
+
+.nav-fill {
+  .nav-item {
+    flex: 1 1 auto;
+    text-align: center;
+  }
+}
+
+.nav-justified {
+  .nav-item {
+    flex-basis: 0;
+    flex-grow: 1;
+    text-align: center;
+  }
+}
+
+
+// Tabbable tabs
+//
+// Hide tabbable panes to start, show them when `.active`
+
+.tab-content {
+  > .tab-pane {
+    display: none;
+  }
+  > .active {
+    display: block;
+  }
+}

+ 299 - 311
src/client/styles/bootstrap4/_navbar.scss

@@ -1,311 +1,299 @@
-// Contents
-//
-// Navbar
-// Navbar brand
-// Navbar nav
-// Navbar text
-// Navbar divider
-// Responsive navbar
-// Navbar position
-// Navbar themes
-
-
-// Navbar
-//
-// Provide a static navbar from which we expand to create full-width, fixed, and
-// other navbar variations.
-
-.navbar {
-  position: relative;
-  display: flex;
-  flex-wrap: wrap; // allow us to do the line break for collapsing content
-  align-items: center;
-  justify-content: space-between; // space out brand from logo
-  padding: $navbar-padding-y $navbar-padding-x;
-
-  // Because flex properties aren't inherited, we need to redeclare these first
-  // few properities so that content nested within behave properly.
-  > .container,
-  > .container-fluid {
-    display: flex;
-    flex-wrap: wrap;
-    align-items: center;
-    justify-content: space-between;
-  }
-}
-
-
-// Navbar brand
-//
-// Used for brand, project, or site names.
-
-.navbar-brand {
-  display: inline-block;
-  padding-top: $navbar-brand-padding-y;
-  padding-bottom: $navbar-brand-padding-y;
-  margin-right: $navbar-padding-x;
-  font-size: $navbar-brand-font-size;
-  line-height: inherit;
-  white-space: nowrap;
-
-  @include hover-focus {
-    text-decoration: none;
-  }
-}
-
-
-// Navbar nav
-//
-// Custom navbar navigation (doesn't require `.nav`, but does make use of `.nav-link`).
-
-.navbar-nav {
-  display: flex;
-  flex-direction: column; // cannot use `inherit` to get the `.navbar`s value
-  padding-left: 0;
-  margin-bottom: 0;
-  list-style: none;
-
-  .nav-link {
-    padding-right: 0;
-    padding-left: 0;
-  }
-
-  .dropdown-menu {
-    position: static;
-    float: none;
-  }
-}
-
-
-// Navbar text
-//
-//
-
-.navbar-text {
-  display: inline-block;
-  padding-top: $nav-link-padding-y;
-  padding-bottom: $nav-link-padding-y;
-}
-
-
-// Responsive navbar
-//
-// Custom styles for responsive collapsing and toggling of navbar contents.
-// Powered by the collapse Bootstrap JavaScript plugin.
-
-// When collapsed, prevent the toggleable navbar contents from appearing in
-// the default flexbox row orienation. Requires the use of `flex-wrap: wrap`
-// on the `.navbar` parent.
-.navbar-collapse {
-  flex-basis: 100%;
-  flex-grow: 1;
-  // For always expanded or extra full navbars, ensure content aligns itself
-  // properly vertically. Can be easily overridden with flex utilities.
-  align-items: center;
-}
-
-// Button for toggling the navbar when in its collapsed state
-.navbar-toggler {
-  padding: $navbar-toggler-padding-y $navbar-toggler-padding-x;
-  font-size: $navbar-toggler-font-size;
-  line-height: 1;
-  background-color: transparent; // remove default button style
-  border: $border-width solid transparent; // remove default button style
-  @include border-radius($navbar-toggler-border-radius);
-
-  @include hover-focus {
-    text-decoration: none;
-  }
-
-  // Opinionated: add "hand" cursor to non-disabled .navbar-toggler elements
-  &:not(:disabled):not(.disabled) {
-    cursor: pointer;
-  }
-}
-
-// Keep as a separate element so folks can easily override it with another icon
-// or image file as needed.
-.navbar-toggler-icon {
-  display: inline-block;
-  width: 1.5em;
-  height: 1.5em;
-  vertical-align: middle;
-  content: "";
-  background: no-repeat center center;
-  background-size: 100% 100%;
-}
-
-// Generate series of `.navbar-expand-*` responsive classes for configuring
-// where your navbar collapses.
-.navbar-expand {
-  @each $breakpoint in map-keys($grid-breakpoints) {
-    $next: breakpoint-next($breakpoint, $grid-breakpoints);
-    $infix: breakpoint-infix($next, $grid-breakpoints);
-
-    &#{$infix} {
-      @include media-breakpoint-down($breakpoint) {
-        > .container,
-        > .container-fluid {
-          padding-right: 0;
-          padding-left: 0;
-        }
-      }
-
-      @include media-breakpoint-up($next) {
-        flex-flow: row nowrap;
-        justify-content: flex-start;
-
-        .navbar-nav {
-          flex-direction: row;
-
-          .dropdown-menu {
-            position: absolute;
-          }
-
-          .dropdown-menu-right {
-            right: 0;
-            left: auto; // Reset the default from `.dropdown-menu`
-          }
-
-          .nav-link {
-            padding-right: $navbar-nav-link-padding-x;
-            padding-left: $navbar-nav-link-padding-x;
-          }
-        }
-
-        // For nesting containers, have to redeclare for alignment purposes
-        > .container,
-        > .container-fluid {
-          flex-wrap: nowrap;
-        }
-
-        .navbar-collapse {
-          display: flex !important;  // stylelint-disable-line declaration-no-important
-
-          // Changes flex-bases to auto because of an IE10 bug
-          flex-basis: auto;
-        }
-
-        .navbar-toggler {
-          display: none;
-        }
-
-        .dropup {
-          .dropdown-menu {
-            top: auto;
-            bottom: 100%;
-          }
-        }
-      }
-    }
-  }
-}
-
-
-// Navbar themes
-//
-// Styles for switching between navbars with light or dark background.
-
-// Dark links against a light background
-.navbar-light {
-  .navbar-brand {
-    color: $navbar-light-active-color;
-
-    @include hover-focus {
-      color: $navbar-light-active-color;
-    }
-  }
-
-  .navbar-nav {
-    .nav-link {
-      color: $navbar-light-color;
-
-      @include hover-focus {
-        color: $navbar-light-hover-color;
-      }
-
-      &.disabled {
-        color: $navbar-light-disabled-color;
-      }
-    }
-
-    .show > .nav-link,
-    .active > .nav-link,
-    .nav-link.show,
-    .nav-link.active {
-      color: $navbar-light-active-color;
-    }
-  }
-
-  .navbar-toggler {
-    color: $navbar-light-color;
-    border-color: $navbar-light-toggler-border-color;
-  }
-
-  .navbar-toggler-icon {
-    background-image: $navbar-light-toggler-icon-bg;
-  }
-
-  .navbar-text {
-    color: $navbar-light-color;
-    a {
-      color: $navbar-light-active-color;
-
-      @include hover-focus {
-        color: $navbar-light-active-color;
-      }
-    }
-  }
-}
-
-// White links against a dark background
-.navbar-dark {
-  .navbar-brand {
-    color: $navbar-dark-active-color;
-
-    @include hover-focus {
-      color: $navbar-dark-active-color;
-    }
-  }
-
-  .navbar-nav {
-    .nav-link {
-      color: $navbar-dark-color;
-
-      @include hover-focus {
-        color: $navbar-dark-hover-color;
-      }
-
-      &.disabled {
-        color: $navbar-dark-disabled-color;
-      }
-    }
-
-    .show > .nav-link,
-    .active > .nav-link,
-    .nav-link.show,
-    .nav-link.active {
-      color: $navbar-dark-active-color;
-    }
-  }
-
-  .navbar-toggler {
-    color: $navbar-dark-color;
-    border-color: $navbar-dark-toggler-border-color;
-  }
-
-  .navbar-toggler-icon {
-    background-image: $navbar-dark-toggler-icon-bg;
-  }
-
-  .navbar-text {
-    color: $navbar-dark-color;
-    a {
-      color: $navbar-dark-active-color;
-
-      @include hover-focus {
-        color: $navbar-dark-active-color;
-      }
-    }
-  }
-}
+// Contents
+//
+// Navbar
+// Navbar brand
+// Navbar nav
+// Navbar text
+// Navbar divider
+// Responsive navbar
+// Navbar position
+// Navbar themes
+
+
+// Navbar
+//
+// Provide a static navbar from which we expand to create full-width, fixed, and
+// other navbar variations.
+
+.navbar {
+  position: relative;
+  display: flex;
+  flex-wrap: wrap; // allow us to do the line break for collapsing content
+  align-items: center;
+  justify-content: space-between; // space out brand from logo
+  padding: $navbar-padding-y $navbar-padding-x;
+
+  // Because flex properties aren't inherited, we need to redeclare these first
+  // few properties so that content nested within behave properly.
+  > .container,
+  > .container-fluid {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+  }
+}
+
+
+// Navbar brand
+//
+// Used for brand, project, or site names.
+
+.navbar-brand {
+  display: inline-block;
+  padding-top: $navbar-brand-padding-y;
+  padding-bottom: $navbar-brand-padding-y;
+  margin-right: $navbar-padding-x;
+  font-size: $navbar-brand-font-size;
+  line-height: inherit;
+  white-space: nowrap;
+
+  @include hover-focus {
+    text-decoration: none;
+  }
+}
+
+
+// Navbar nav
+//
+// Custom navbar navigation (doesn't require `.nav`, but does make use of `.nav-link`).
+
+.navbar-nav {
+  display: flex;
+  flex-direction: column; // cannot use `inherit` to get the `.navbar`s value
+  padding-left: 0;
+  margin-bottom: 0;
+  list-style: none;
+
+  .nav-link {
+    padding-right: 0;
+    padding-left: 0;
+  }
+
+  .dropdown-menu {
+    position: static;
+    float: none;
+  }
+}
+
+
+// Navbar text
+//
+//
+
+.navbar-text {
+  display: inline-block;
+  padding-top: $nav-link-padding-y;
+  padding-bottom: $nav-link-padding-y;
+}
+
+
+// Responsive navbar
+//
+// Custom styles for responsive collapsing and toggling of navbar contents.
+// Powered by the collapse Bootstrap JavaScript plugin.
+
+// When collapsed, prevent the toggleable navbar contents from appearing in
+// the default flexbox row orientation. Requires the use of `flex-wrap: wrap`
+// on the `.navbar` parent.
+.navbar-collapse {
+  flex-basis: 100%;
+  flex-grow: 1;
+  // For always expanded or extra full navbars, ensure content aligns itself
+  // properly vertically. Can be easily overridden with flex utilities.
+  align-items: center;
+}
+
+// Button for toggling the navbar when in its collapsed state
+.navbar-toggler {
+  padding: $navbar-toggler-padding-y $navbar-toggler-padding-x;
+  font-size: $navbar-toggler-font-size;
+  line-height: 1;
+  background-color: transparent; // remove default button style
+  border: $border-width solid transparent; // remove default button style
+  @include border-radius($navbar-toggler-border-radius);
+
+  @include hover-focus {
+    text-decoration: none;
+  }
+
+  // Opinionated: add "hand" cursor to non-disabled .navbar-toggler elements
+  &:not(:disabled):not(.disabled) {
+    cursor: pointer;
+  }
+}
+
+// Keep as a separate element so folks can easily override it with another icon
+// or image file as needed.
+.navbar-toggler-icon {
+  display: inline-block;
+  width: 1.5em;
+  height: 1.5em;
+  vertical-align: middle;
+  content: "";
+  background: no-repeat center center;
+  background-size: 100% 100%;
+}
+
+// Generate series of `.navbar-expand-*` responsive classes for configuring
+// where your navbar collapses.
+.navbar-expand {
+  @each $breakpoint in map-keys($grid-breakpoints) {
+    $next: breakpoint-next($breakpoint, $grid-breakpoints);
+    $infix: breakpoint-infix($next, $grid-breakpoints);
+
+    &#{$infix} {
+      @include media-breakpoint-down($breakpoint) {
+        > .container,
+        > .container-fluid {
+          padding-right: 0;
+          padding-left: 0;
+        }
+      }
+
+      @include media-breakpoint-up($next) {
+        flex-flow: row nowrap;
+        justify-content: flex-start;
+
+        .navbar-nav {
+          flex-direction: row;
+
+          .dropdown-menu {
+            position: absolute;
+          }
+
+          .nav-link {
+            padding-right: $navbar-nav-link-padding-x;
+            padding-left: $navbar-nav-link-padding-x;
+          }
+        }
+
+        // For nesting containers, have to redeclare for alignment purposes
+        > .container,
+        > .container-fluid {
+          flex-wrap: nowrap;
+        }
+
+        .navbar-collapse {
+          display: flex !important;  // stylelint-disable-line declaration-no-important
+
+          // Changes flex-bases to auto because of an IE10 bug
+          flex-basis: auto;
+        }
+
+        .navbar-toggler {
+          display: none;
+        }
+      }
+    }
+  }
+}
+
+
+// Navbar themes
+//
+// Styles for switching between navbars with light or dark background.
+
+// Dark links against a light background
+.navbar-light {
+  .navbar-brand {
+    color: $navbar-light-active-color;
+
+    @include hover-focus {
+      color: $navbar-light-active-color;
+    }
+  }
+
+  .navbar-nav {
+    .nav-link {
+      color: $navbar-light-color;
+
+      @include hover-focus {
+        color: $navbar-light-hover-color;
+      }
+
+      &.disabled {
+        color: $navbar-light-disabled-color;
+      }
+    }
+
+    .show > .nav-link,
+    .active > .nav-link,
+    .nav-link.show,
+    .nav-link.active {
+      color: $navbar-light-active-color;
+    }
+  }
+
+  .navbar-toggler {
+    color: $navbar-light-color;
+    border-color: $navbar-light-toggler-border-color;
+  }
+
+  .navbar-toggler-icon {
+    background-image: $navbar-light-toggler-icon-bg;
+  }
+
+  .navbar-text {
+    color: $navbar-light-color;
+    a {
+      color: $navbar-light-active-color;
+
+      @include hover-focus {
+        color: $navbar-light-active-color;
+      }
+    }
+  }
+}
+
+// White links against a dark background
+.navbar-dark {
+  .navbar-brand {
+    color: $navbar-dark-active-color;
+
+    @include hover-focus {
+      color: $navbar-dark-active-color;
+    }
+  }
+
+  .navbar-nav {
+    .nav-link {
+      color: $navbar-dark-color;
+
+      @include hover-focus {
+        color: $navbar-dark-hover-color;
+      }
+
+      &.disabled {
+        color: $navbar-dark-disabled-color;
+      }
+    }
+
+    .show > .nav-link,
+    .active > .nav-link,
+    .nav-link.show,
+    .nav-link.active {
+      color: $navbar-dark-active-color;
+    }
+  }
+
+  .navbar-toggler {
+    color: $navbar-dark-color;
+    border-color: $navbar-dark-toggler-border-color;
+  }
+
+  .navbar-toggler-icon {
+    background-image: $navbar-dark-toggler-icon-bg;
+  }
+
+  .navbar-text {
+    color: $navbar-dark-color;
+    a {
+      color: $navbar-dark-active-color;
+
+      @include hover-focus {
+        color: $navbar-dark-active-color;
+      }
+    }
+  }
+}

+ 78 - 77
src/client/styles/bootstrap4/_pagination.scss

@@ -1,77 +1,78 @@
-.pagination {
-  display: flex;
-  @include list-unstyled();
-  @include border-radius();
-}
-
-.page-link {
-  position: relative;
-  display: block;
-  padding: $pagination-padding-y $pagination-padding-x;
-  margin-left: -$pagination-border-width;
-  line-height: $pagination-line-height;
-  color: $pagination-color;
-  background-color: $pagination-bg;
-  border: $pagination-border-width solid $pagination-border-color;
-
-  &:hover {
-    color: $pagination-hover-color;
-    text-decoration: none;
-    background-color: $pagination-hover-bg;
-    border-color: $pagination-hover-border-color;
-  }
-
-  &:focus {
-    z-index: 2;
-    outline: 0;
-    box-shadow: $pagination-focus-box-shadow;
-  }
-
-  // Opinionated: add "hand" cursor to non-disabled .page-link elements
-  &:not(:disabled):not(.disabled) {
-    cursor: pointer;
-  }
-}
-
-.page-item {
-  &:first-child {
-    .page-link {
-      margin-left: 0;
-      @include border-left-radius($border-radius);
-    }
-  }
-  &:last-child {
-    .page-link {
-      @include border-right-radius($border-radius);
-    }
-  }
-
-  &.active .page-link {
-    z-index: 1;
-    color: $pagination-active-color;
-    background-color: $pagination-active-bg;
-    border-color: $pagination-active-border-color;
-  }
-
-  &.disabled .page-link {
-    color: $pagination-disabled-color;
-    pointer-events: none;
-    // Opinionated: remove the "hand" cursor set previously for .page-link
-    cursor: auto;
-    background-color: $pagination-disabled-bg;
-    border-color: $pagination-disabled-border-color;
-  }
-}
-
-
-//
-// Sizing
-//
-
-.pagination-lg {
-  @include pagination-size($pagination-padding-y-lg, $pagination-padding-x-lg, $font-size-lg, $line-height-lg, $border-radius-lg);
-}
-
-.pagination-sm {
-  @include pagination-size($pagination-padding-y-sm, $pagination-padding-x-sm, $font-size-sm, $line-height-sm, $border-radius-sm);
-}
+.pagination {
+  display: flex;
+  @include list-unstyled();
+  @include border-radius();
+}
+
+.page-link {
+  position: relative;
+  display: block;
+  padding: $pagination-padding-y $pagination-padding-x;
+  margin-left: -$pagination-border-width;
+  line-height: $pagination-line-height;
+  color: $pagination-color;
+  background-color: $pagination-bg;
+  border: $pagination-border-width solid $pagination-border-color;
+
+  &:hover {
+    z-index: 2;
+    color: $pagination-hover-color;
+    text-decoration: none;
+    background-color: $pagination-hover-bg;
+    border-color: $pagination-hover-border-color;
+  }
+
+  &:focus {
+    z-index: 2;
+    outline: $pagination-focus-outline;
+    box-shadow: $pagination-focus-box-shadow;
+  }
+
+  // Opinionated: add "hand" cursor to non-disabled .page-link elements
+  &:not(:disabled):not(.disabled) {
+    cursor: pointer;
+  }
+}
+
+.page-item {
+  &:first-child {
+    .page-link {
+      margin-left: 0;
+      @include border-left-radius($border-radius);
+    }
+  }
+  &:last-child {
+    .page-link {
+      @include border-right-radius($border-radius);
+    }
+  }
+
+  &.active .page-link {
+    z-index: 1;
+    color: $pagination-active-color;
+    background-color: $pagination-active-bg;
+    border-color: $pagination-active-border-color;
+  }
+
+  &.disabled .page-link {
+    color: $pagination-disabled-color;
+    pointer-events: none;
+    // Opinionated: remove the "hand" cursor set previously for .page-link
+    cursor: auto;
+    background-color: $pagination-disabled-bg;
+    border-color: $pagination-disabled-border-color;
+  }
+}
+
+
+//
+// Sizing
+//
+
+.pagination-lg {
+  @include pagination-size($pagination-padding-y-lg, $pagination-padding-x-lg, $font-size-lg, $line-height-lg, $border-radius-lg);
+}
+
+.pagination-sm {
+  @include pagination-size($pagination-padding-y-sm, $pagination-padding-x-sm, $font-size-sm, $line-height-sm, $border-radius-sm);
+}

+ 183 - 183
src/client/styles/bootstrap4/_popover.scss

@@ -1,183 +1,183 @@
-.popover {
-  position: absolute;
-  top: 0;
-  left: 0;
-  z-index: $zindex-popover;
-  display: block;
-  max-width: $popover-max-width;
-  // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
-  // So reset our font and text properties to avoid inheriting weird values.
-  @include reset-text();
-  font-size: $popover-font-size;
-  // Allow breaking very long words so they don't overflow the popover's bounds
-  word-wrap: break-word;
-  background-color: $popover-bg;
-  background-clip: padding-box;
-  border: $popover-border-width solid $popover-border-color;
-  @include border-radius($popover-border-radius);
-  @include box-shadow($popover-box-shadow);
-
-  .arrow {
-    position: absolute;
-    display: block;
-    width: $popover-arrow-width;
-    height: $popover-arrow-height;
-    margin: 0 $border-radius-lg;
-
-    &::before,
-    &::after {
-      position: absolute;
-      display: block;
-      content: "";
-      border-color: transparent;
-      border-style: solid;
-    }
-  }
-}
-
-.bs-popover-top {
-  margin-bottom: $popover-arrow-height;
-
-  .arrow {
-    bottom: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
-  }
-
-  .arrow::before,
-  .arrow::after {
-    border-width: $popover-arrow-height ($popover-arrow-width / 2) 0;
-  }
-
-  .arrow::before {
-    bottom: 0;
-    border-top-color: $popover-arrow-outer-color;
-  }
-
-  .arrow::after {
-    bottom: $popover-border-width;
-    border-top-color: $popover-arrow-color;
-  }
-}
-
-.bs-popover-right {
-  margin-left: $popover-arrow-height;
-
-  .arrow {
-    left: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
-    width: $popover-arrow-height;
-    height: $popover-arrow-width;
-    margin: $border-radius-lg 0; // make sure the arrow does not touch the popover's rounded corners
-  }
-
-  .arrow::before,
-  .arrow::after {
-    border-width: ($popover-arrow-width / 2) $popover-arrow-height ($popover-arrow-width / 2) 0;
-  }
-
-  .arrow::before {
-    left: 0;
-    border-right-color: $popover-arrow-outer-color;
-  }
-
-  .arrow::after {
-    left: $popover-border-width;
-    border-right-color: $popover-arrow-color;
-  }
-}
-
-.bs-popover-bottom {
-  margin-top: $popover-arrow-height;
-
-  .arrow {
-    top: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
-  }
-
-  .arrow::before,
-  .arrow::after {
-    border-width: 0 ($popover-arrow-width / 2) $popover-arrow-height ($popover-arrow-width / 2);
-  }
-
-  .arrow::before {
-    top: 0;
-    border-bottom-color: $popover-arrow-outer-color;
-  }
-
-  .arrow::after {
-    top: $popover-border-width;
-    border-bottom-color: $popover-arrow-color;
-  }
-
-  // This will remove the popover-header's border just below the arrow
-  .popover-header::before {
-    position: absolute;
-    top: 0;
-    left: 50%;
-    display: block;
-    width: $popover-arrow-width;
-    margin-left: ($popover-arrow-width / -2);
-    content: "";
-    border-bottom: $popover-border-width solid $popover-header-bg;
-  }
-}
-
-.bs-popover-left {
-  margin-right: $popover-arrow-height;
-
-  .arrow {
-    right: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
-    width: $popover-arrow-height;
-    height: $popover-arrow-width;
-    margin: $border-radius-lg 0; // make sure the arrow does not touch the popover's rounded corners
-  }
-
-  .arrow::before,
-  .arrow::after {
-    border-width: ($popover-arrow-width / 2) 0 ($popover-arrow-width / 2) $popover-arrow-height;
-  }
-
-  .arrow::before {
-    right: 0;
-    border-left-color: $popover-arrow-outer-color;
-  }
-
-  .arrow::after {
-    right: $popover-border-width;
-    border-left-color: $popover-arrow-color;
-  }
-}
-
-.bs-popover-auto {
-  &[x-placement^="top"] {
-    @extend .bs-popover-top;
-  }
-  &[x-placement^="right"] {
-    @extend .bs-popover-right;
-  }
-  &[x-placement^="bottom"] {
-    @extend .bs-popover-bottom;
-  }
-  &[x-placement^="left"] {
-    @extend .bs-popover-left;
-  }
-}
-
-
-// Offset the popover to account for the popover arrow
-.popover-header {
-  padding: $popover-header-padding-y $popover-header-padding-x;
-  margin-bottom: 0; // Reset the default from Reboot
-  font-size: $font-size-base;
-  color: $popover-header-color;
-  background-color: $popover-header-bg;
-  border-bottom: $popover-border-width solid darken($popover-header-bg, 5%);
-  $offset-border-width: calc(#{$border-radius-lg} - #{$popover-border-width});
-  @include border-top-radius($offset-border-width);
-
-  &:empty {
-    display: none;
-  }
-}
-
-.popover-body {
-  padding: $popover-body-padding-y $popover-body-padding-x;
-  color: $popover-body-color;
-}
+.popover {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: $zindex-popover;
+  display: block;
+  max-width: $popover-max-width;
+  // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
+  // So reset our font and text properties to avoid inheriting weird values.
+  @include reset-text();
+  font-size: $popover-font-size;
+  // Allow breaking very long words so they don't overflow the popover's bounds
+  word-wrap: break-word;
+  background-color: $popover-bg;
+  background-clip: padding-box;
+  border: $popover-border-width solid $popover-border-color;
+  @include border-radius($popover-border-radius);
+  @include box-shadow($popover-box-shadow);
+
+  .arrow {
+    position: absolute;
+    display: block;
+    width: $popover-arrow-width;
+    height: $popover-arrow-height;
+    margin: 0 $border-radius-lg;
+
+    &::before,
+    &::after {
+      position: absolute;
+      display: block;
+      content: "";
+      border-color: transparent;
+      border-style: solid;
+    }
+  }
+}
+
+.bs-popover-top {
+  margin-bottom: $popover-arrow-height;
+
+  .arrow {
+    bottom: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
+  }
+
+  .arrow::before,
+  .arrow::after {
+    border-width: $popover-arrow-height ($popover-arrow-width / 2) 0;
+  }
+
+  .arrow::before {
+    bottom: 0;
+    border-top-color: $popover-arrow-outer-color;
+  }
+
+  .arrow::after {
+    bottom: $popover-border-width;
+    border-top-color: $popover-arrow-color;
+  }
+}
+
+.bs-popover-right {
+  margin-left: $popover-arrow-height;
+
+  .arrow {
+    left: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
+    width: $popover-arrow-height;
+    height: $popover-arrow-width;
+    margin: $border-radius-lg 0; // make sure the arrow does not touch the popover's rounded corners
+  }
+
+  .arrow::before,
+  .arrow::after {
+    border-width: ($popover-arrow-width / 2) $popover-arrow-height ($popover-arrow-width / 2) 0;
+  }
+
+  .arrow::before {
+    left: 0;
+    border-right-color: $popover-arrow-outer-color;
+  }
+
+  .arrow::after {
+    left: $popover-border-width;
+    border-right-color: $popover-arrow-color;
+  }
+}
+
+.bs-popover-bottom {
+  margin-top: $popover-arrow-height;
+
+  .arrow {
+    top: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
+  }
+
+  .arrow::before,
+  .arrow::after {
+    border-width: 0 ($popover-arrow-width / 2) $popover-arrow-height ($popover-arrow-width / 2);
+  }
+
+  .arrow::before {
+    top: 0;
+    border-bottom-color: $popover-arrow-outer-color;
+  }
+
+  .arrow::after {
+    top: $popover-border-width;
+    border-bottom-color: $popover-arrow-color;
+  }
+
+  // This will remove the popover-header's border just below the arrow
+  .popover-header::before {
+    position: absolute;
+    top: 0;
+    left: 50%;
+    display: block;
+    width: $popover-arrow-width;
+    margin-left: ($popover-arrow-width / -2);
+    content: "";
+    border-bottom: $popover-border-width solid $popover-header-bg;
+  }
+}
+
+.bs-popover-left {
+  margin-right: $popover-arrow-height;
+
+  .arrow {
+    right: calc((#{$popover-arrow-height} + #{$popover-border-width}) * -1);
+    width: $popover-arrow-height;
+    height: $popover-arrow-width;
+    margin: $border-radius-lg 0; // make sure the arrow does not touch the popover's rounded corners
+  }
+
+  .arrow::before,
+  .arrow::after {
+    border-width: ($popover-arrow-width / 2) 0 ($popover-arrow-width / 2) $popover-arrow-height;
+  }
+
+  .arrow::before {
+    right: 0;
+    border-left-color: $popover-arrow-outer-color;
+  }
+
+  .arrow::after {
+    right: $popover-border-width;
+    border-left-color: $popover-arrow-color;
+  }
+}
+
+.bs-popover-auto {
+  &[x-placement^="top"] {
+    @extend .bs-popover-top;
+  }
+  &[x-placement^="right"] {
+    @extend .bs-popover-right;
+  }
+  &[x-placement^="bottom"] {
+    @extend .bs-popover-bottom;
+  }
+  &[x-placement^="left"] {
+    @extend .bs-popover-left;
+  }
+}
+
+
+// Offset the popover to account for the popover arrow
+.popover-header {
+  padding: $popover-header-padding-y $popover-header-padding-x;
+  margin-bottom: 0; // Reset the default from Reboot
+  font-size: $font-size-base;
+  color: $popover-header-color;
+  background-color: $popover-header-bg;
+  border-bottom: $popover-border-width solid darken($popover-header-bg, 5%);
+  $offset-border-width: calc(#{$border-radius-lg} - #{$popover-border-width});
+  @include border-top-radius($offset-border-width);
+
+  &:empty {
+    display: none;
+  }
+}
+
+.popover-body {
+  padding: $popover-body-padding-y $popover-body-padding-x;
+  color: $popover-body-color;
+}

+ 141 - 124
src/client/styles/bootstrap4/_print.scss

@@ -1,124 +1,141 @@
-// stylelint-disable declaration-no-important, selector-no-qualifying-type
-
-// Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css
-
-// ==========================================================================
-// Print styles.
-// Inlined to avoid the additional HTTP request:
-// http://www.phpied.com/delay-loading-your-print-css/
-// ==========================================================================
-
-@if $enable-print-styles {
-  @media print {
-    *,
-    *::before,
-    *::after {
-      // Bootstrap specific; comment out `color` and `background`
-      //color: #000 !important; // Black prints faster: http://www.sanbeiji.com/archives/953
-      text-shadow: none !important;
-      //background: transparent !important;
-      box-shadow: none !important;
-    }
-
-    a {
-      &:not(.btn) {
-        text-decoration: underline;
-      }
-    }
-
-    // Bootstrap specific; comment the following selector out
-    //a[href]::after {
-    //  content: " (" attr(href) ")";
-    //}
-
-    abbr[title]::after {
-      content: " (" attr(title) ")";
-    }
-
-    // Bootstrap specific; comment the following selector out
-    //
-    // Don't show links that are fragment identifiers,
-    // or use the `javascript:` pseudo protocol
-    //
-
-    //a[href^="#"]::after,
-    //a[href^="javascript:"]::after {
-    // content: "";
-    //}
-
-    pre {
-      white-space: pre-wrap !important;
-    }
-    pre,
-    blockquote {
-      border: $border-width solid #999;   // Bootstrap custom code; using `$border-width` instead of 1px
-      page-break-inside: avoid;
-    }
-
-    //
-    // Printing Tables:
-    // http://css-discuss.incutio.com/wiki/Printing_Tables
-    //
-
-    thead {
-      display: table-header-group;
-    }
-
-    tr,
-    img {
-      page-break-inside: avoid;
-    }
-
-    p,
-    h2,
-    h3 {
-      orphans: 3;
-      widows: 3;
-    }
-
-    h2,
-    h3 {
-      page-break-after: avoid;
-    }
-
-    // Bootstrap specific changes start
-
-    // Specify a size and min-width to make printing closer across browsers.
-    // We don't set margin here because it breaks `size` in Chrome. We also
-    // don't use `!important` on `size` as it breaks in Chrome.
-    @page {
-      size: $print-page-size;
-    }
-    body {
-      min-width: $print-body-min-width !important;
-    }
-    .container {
-      min-width: $print-body-min-width !important;
-    }
-
-    // Bootstrap components
-    .navbar {
-      display: none;
-    }
-    .badge {
-      border: $border-width solid #000;
-    }
-
-    .table {
-      border-collapse: collapse !important;
-
-      td,
-      th {
-        background-color: #fff !important;
-      }
-    }
-    .table-bordered {
-      th,
-      td {
-        border: 1px solid #ddd !important;
-      }
-    }
-
-    // Bootstrap specific changes end
-  }
-}
+// stylelint-disable declaration-no-important, selector-no-qualifying-type
+
+// Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css
+
+// ==========================================================================
+// Print styles.
+// Inlined to avoid the additional HTTP request:
+// https://www.phpied.com/delay-loading-your-print-css/
+// ==========================================================================
+
+@if $enable-print-styles {
+  @media print {
+    *,
+    *::before,
+    *::after {
+      // Bootstrap specific; comment out `color` and `background`
+      //color: $black !important; // Black prints faster
+      text-shadow: none !important;
+      //background: transparent !important;
+      box-shadow: none !important;
+    }
+
+    a {
+      &:not(.btn) {
+        text-decoration: underline;
+      }
+    }
+
+    // Bootstrap specific; comment the following selector out
+    //a[href]::after {
+    //  content: " (" attr(href) ")";
+    //}
+
+    abbr[title]::after {
+      content: " (" attr(title) ")";
+    }
+
+    // Bootstrap specific; comment the following selector out
+    //
+    // Don't show links that are fragment identifiers,
+    // or use the `javascript:` pseudo protocol
+    //
+
+    //a[href^="#"]::after,
+    //a[href^="javascript:"]::after {
+    // content: "";
+    //}
+
+    pre {
+      white-space: pre-wrap !important;
+    }
+    pre,
+    blockquote {
+      border: $border-width solid $gray-500;   // Bootstrap custom code; using `$border-width` instead of 1px
+      page-break-inside: avoid;
+    }
+
+    //
+    // Printing Tables:
+    // http://css-discuss.incutio.com/wiki/Printing_Tables
+    //
+
+    thead {
+      display: table-header-group;
+    }
+
+    tr,
+    img {
+      page-break-inside: avoid;
+    }
+
+    p,
+    h2,
+    h3 {
+      orphans: 3;
+      widows: 3;
+    }
+
+    h2,
+    h3 {
+      page-break-after: avoid;
+    }
+
+    // Bootstrap specific changes start
+
+    // Specify a size and min-width to make printing closer across browsers.
+    // We don't set margin here because it breaks `size` in Chrome. We also
+    // don't use `!important` on `size` as it breaks in Chrome.
+    @page {
+      size: $print-page-size;
+    }
+    body {
+      min-width: $print-body-min-width !important;
+    }
+    .container {
+      min-width: $print-body-min-width !important;
+    }
+
+    // Bootstrap components
+    .navbar {
+      display: none;
+    }
+    .badge {
+      border: $border-width solid $black;
+    }
+
+    .table {
+      border-collapse: collapse !important;
+
+      td,
+      th {
+        background-color: $white !important;
+      }
+    }
+
+    .table-bordered {
+      th,
+      td {
+        border: 1px solid $gray-300 !important;
+      }
+    }
+
+    .table-dark {
+      color: inherit;
+
+      th,
+      td,
+      thead th,
+      tbody + tbody {
+        border-color: $table-border-color;
+      }
+    }
+
+    .table .thead-dark th {
+      color: inherit;
+      border-color: $table-border-color;
+    }
+
+    // Bootstrap specific changes end
+  }
+}

+ 34 - 33
src/client/styles/bootstrap4/_progress.scss

@@ -1,33 +1,34 @@
-@keyframes progress-bar-stripes {
-  from { background-position: $progress-height 0; }
-  to { background-position: 0 0; }
-}
-
-.progress {
-  display: flex;
-  height: $progress-height;
-  overflow: hidden; // force rounded corners by cropping it
-  font-size: $progress-font-size;
-  background-color: $progress-bg;
-  @include border-radius($progress-border-radius);
-  @include box-shadow($progress-box-shadow);
-}
-
-.progress-bar {
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  color: $progress-bar-color;
-  text-align: center;
-  background-color: $progress-bar-bg;
-  @include transition($progress-bar-transition);
-}
-
-.progress-bar-striped {
-  @include gradient-striped();
-  background-size: $progress-height $progress-height;
-}
-
-.progress-bar-animated {
-  animation: progress-bar-stripes $progress-bar-animation-timing;
-}
+@keyframes progress-bar-stripes {
+  from { background-position: $progress-height 0; }
+  to { background-position: 0 0; }
+}
+
+.progress {
+  display: flex;
+  height: $progress-height;
+  overflow: hidden; // force rounded corners by cropping it
+  font-size: $progress-font-size;
+  background-color: $progress-bg;
+  @include border-radius($progress-border-radius);
+  @include box-shadow($progress-box-shadow);
+}
+
+.progress-bar {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  color: $progress-bar-color;
+  text-align: center;
+  white-space: nowrap;
+  background-color: $progress-bar-bg;
+  @include transition($progress-bar-transition);
+}
+
+.progress-bar-striped {
+  @include gradient-striped();
+  background-size: $progress-height $progress-height;
+}
+
+.progress-bar-animated {
+  animation: progress-bar-stripes $progress-bar-animation-timing;
+}

+ 483 - 482
src/client/styles/bootstrap4/_reboot.scss

@@ -1,482 +1,483 @@
-// stylelint-disable at-rule-no-vendor-prefix, declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix
-
-// Reboot
-//
-// Normalization of HTML elements, manually forked from Normalize.css to remove
-// styles targeting irrelevant browsers while applying new styles.
-//
-// Normalize is licensed MIT. https://github.com/necolas/normalize.css
-
-
-// Document
-//
-// 1. Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.
-// 2. Change the default font family in all browsers.
-// 3. Correct the line height in all browsers.
-// 4. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS.
-// 5. Setting @viewport causes scrollbars to overlap content in IE11 and Edge, so
-//    we force a non-overlapping, non-auto-hiding scrollbar to counteract.
-// 6. Change the default tap highlight to be completely transparent in iOS.
-
-*,
-*::before,
-*::after {
-  box-sizing: border-box; // 1
-}
-
-html {
-  font-family: sans-serif; // 2
-  line-height: 1.15; // 3
-  -webkit-text-size-adjust: 100%; // 4
-  -ms-text-size-adjust: 100%; // 4
-  -ms-overflow-style: scrollbar; // 5
-  -webkit-tap-highlight-color: rgba(0, 0, 0, 0); // 6
-}
-
-// IE10+ doesn't honor `<meta name="viewport">` in some cases.
-@at-root {
-  @-ms-viewport {
-    width: device-width;
-  }
-}
-
-// stylelint-disable selector-list-comma-newline-after
-// Shim for "new" HTML5 structural elements to display correctly (IE10, older browsers)
-article, aside, dialog, figcaption, figure, footer, header, hgroup, main, nav, section {
-  display: block;
-}
-// stylelint-enable selector-list-comma-newline-after
-
-// Body
-//
-// 1. Remove the margin in all browsers.
-// 2. As a best practice, apply a default `background-color`.
-// 3. Set an explicit initial text-align value so that we can later use the
-//    the `inherit` value on things like `<th>` elements.
-
-body {
-  margin: 0; // 1
-  font-family: $font-family-base;
-  font-size: $font-size-base;
-  font-weight: $font-weight-base;
-  line-height: $line-height-base;
-  color: $body-color;
-  text-align: left; // 3
-  background-color: $body-bg; // 2
-}
-
-// Suppress the focus outline on elements that cannot be accessed via keyboard.
-// This prevents an unwanted focus outline from appearing around elements that
-// might still respond to pointer events.
-//
-// Credit: https://github.com/suitcss/base
-[tabindex="-1"]:focus {
-  outline: 0 !important;
-}
-
-
-// Content grouping
-//
-// 1. Add the correct box sizing in Firefox.
-// 2. Show the overflow in Edge and IE.
-
-hr {
-  box-sizing: content-box; // 1
-  height: 0; // 1
-  overflow: visible; // 2
-}
-
-
-//
-// Typography
-//
-
-// Remove top margins from headings
-//
-// By default, `<h1>`-`<h6>` all receive top and bottom margins. We nuke the top
-// margin for easier control within type scales as it avoids margin collapsing.
-// stylelint-disable selector-list-comma-newline-after
-h1, h2, h3, h4, h5, h6 {
-  margin-top: 0;
-  margin-bottom: $headings-margin-bottom;
-}
-// stylelint-enable selector-list-comma-newline-after
-
-// Reset margins on paragraphs
-//
-// Similarly, the top margin on `<p>`s get reset. However, we also reset the
-// bottom margin to use `rem` units instead of `em`.
-p {
-  margin-top: 0;
-  margin-bottom: $paragraph-margin-bottom;
-}
-
-// Abbreviations
-//
-// 1. Remove the bottom border in Firefox 39-.
-// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
-// 3. Add explicit cursor to indicate changed behavior.
-// 4. Duplicate behavior to the data-* attribute for our tooltip plugin
-
-abbr[title],
-abbr[data-original-title] { // 4
-  text-decoration: underline; // 2
-  text-decoration: underline dotted; // 2
-  cursor: help; // 3
-  border-bottom: 0; // 1
-}
-
-address {
-  margin-bottom: 1rem;
-  font-style: normal;
-  line-height: inherit;
-}
-
-ol,
-ul,
-dl {
-  margin-top: 0;
-  margin-bottom: 1rem;
-}
-
-ol ol,
-ul ul,
-ol ul,
-ul ol {
-  margin-bottom: 0;
-}
-
-dt {
-  font-weight: $dt-font-weight;
-}
-
-dd {
-  margin-bottom: .5rem;
-  margin-left: 0; // Undo browser default
-}
-
-blockquote {
-  margin: 0 0 1rem;
-}
-
-dfn {
-  font-style: italic; // Add the correct font style in Android 4.3-
-}
-
-// stylelint-disable font-weight-notation
-b,
-strong {
-  font-weight: bolder; // Add the correct font weight in Chrome, Edge, and Safari
-}
-// stylelint-enable font-weight-notation
-
-small {
-  font-size: 80%; // Add the correct font size in all browsers
-}
-
-//
-// Prevent `sub` and `sup` elements from affecting the line height in
-// all browsers.
-//
-
-sub,
-sup {
-  position: relative;
-  font-size: 75%;
-  line-height: 0;
-  vertical-align: baseline;
-}
-
-sub { bottom: -.25em; }
-sup { top: -.5em; }
-
-
-//
-// Links
-//
-
-a {
-  color: $link-color;
-  text-decoration: $link-decoration;
-  background-color: transparent; // Remove the gray background on active links in IE 10.
-  -webkit-text-decoration-skip: objects; // Remove gaps in links underline in iOS 8+ and Safari 8+.
-
-  @include hover {
-    color: $link-hover-color;
-    text-decoration: $link-hover-decoration;
-  }
-}
-
-// And undo these styles for placeholder links/named anchors (without href)
-// which have not been made explicitly keyboard-focusable (without tabindex).
-// It would be more straightforward to just use a[href] in previous block, but that
-// causes specificity issues in many other styles that are too complex to fix.
-// See https://github.com/twbs/bootstrap/issues/19402
-
-a:not([href]):not([tabindex]) {
-  color: inherit;
-  text-decoration: none;
-
-  @include hover-focus {
-    color: inherit;
-    text-decoration: none;
-  }
-
-  &:focus {
-    outline: 0;
-  }
-}
-
-
-//
-// Code
-//
-
-// stylelint-disable font-family-no-duplicate-names
-pre,
-code,
-kbd,
-samp {
-  font-family: monospace, monospace; // Correct the inheritance and scaling of font size in all browsers.
-  font-size: 1em; // Correct the odd `em` font sizing in all browsers.
-}
-// stylelint-enable font-family-no-duplicate-names
-
-pre {
-  // Remove browser default top margin
-  margin-top: 0;
-  // Reset browser default of `1em` to use `rem`s
-  margin-bottom: 1rem;
-  // Don't allow content to break outside
-  overflow: auto;
-  // We have @viewport set which causes scrollbars to overlap content in IE11 and Edge, so
-  // we force a non-overlapping, non-auto-hiding scrollbar to counteract.
-  -ms-overflow-style: scrollbar;
-}
-
-
-//
-// Figures
-//
-
-figure {
-  // Apply a consistent margin strategy (matches our type styles).
-  margin: 0 0 1rem;
-}
-
-
-//
-// Images and content
-//
-
-img {
-  vertical-align: middle;
-  border-style: none; // Remove the border on images inside links in IE 10-.
-}
-
-svg:not(:root) {
-  overflow: hidden; // Hide the overflow in IE
-}
-
-
-//
-// Tables
-//
-
-table {
-  border-collapse: collapse; // Prevent double borders
-}
-
-caption {
-  padding-top: $table-cell-padding;
-  padding-bottom: $table-cell-padding;
-  color: $text-muted;
-  text-align: left;
-  caption-side: bottom;
-}
-
-th {
-  // Matches default `<td>` alignment by inheriting from the `<body>`, or the
-  // closest parent with a set `text-align`.
-  text-align: inherit;
-}
-
-
-//
-// Forms
-//
-
-label {
-  // Allow labels to use `margin` for spacing.
-  display: inline-block;
-  margin-bottom: .5rem;
-}
-
-// Remove the default `border-radius` that macOS Chrome adds.
-//
-// Details at https://github.com/twbs/bootstrap/issues/24093
-button {
-  border-radius: 0;
-}
-
-// Work around a Firefox/IE bug where the transparent `button` background
-// results in a loss of the default `button` focus styles.
-//
-// Credit: https://github.com/suitcss/base/
-button:focus {
-  outline: 1px dotted;
-  outline: 5px auto -webkit-focus-ring-color;
-}
-
-input,
-button,
-select,
-optgroup,
-textarea {
-  margin: 0; // Remove the margin in Firefox and Safari
-  font-family: inherit;
-  font-size: inherit;
-  line-height: inherit;
-}
-
-button,
-input {
-  overflow: visible; // Show the overflow in Edge
-}
-
-button,
-select {
-  text-transform: none; // Remove the inheritance of text transform in Firefox
-}
-
-// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
-//    controls in Android 4.
-// 2. Correct the inability to style clickable types in iOS and Safari.
-button,
-html [type="button"], // 1
-[type="reset"],
-[type="submit"] {
-  -webkit-appearance: button; // 2
-}
-
-// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.
-button::-moz-focus-inner,
-[type="button"]::-moz-focus-inner,
-[type="reset"]::-moz-focus-inner,
-[type="submit"]::-moz-focus-inner {
-  padding: 0;
-  border-style: none;
-}
-
-input[type="radio"],
-input[type="checkbox"] {
-  box-sizing: border-box; // 1. Add the correct box sizing in IE 10-
-  padding: 0; // 2. Remove the padding in IE 10-
-}
-
-
-input[type="date"],
-input[type="time"],
-input[type="datetime-local"],
-input[type="month"] {
-  // Remove the default appearance of temporal inputs to avoid a Mobile Safari
-  // bug where setting a custom line-height prevents text from being vertically
-  // centered within the input.
-  // See https://bugs.webkit.org/show_bug.cgi?id=139848
-  // and https://github.com/twbs/bootstrap/issues/11266
-  -webkit-appearance: listbox;
-}
-
-textarea {
-  overflow: auto; // Remove the default vertical scrollbar in IE.
-  // Textareas should really only resize vertically so they don't break their (horizontal) containers.
-  resize: vertical;
-}
-
-fieldset {
-  // Browsers set a default `min-width: min-content;` on fieldsets,
-  // unlike e.g. `<div>`s, which have `min-width: 0;` by default.
-  // So we reset that to ensure fieldsets behave more like a standard block element.
-  // See https://github.com/twbs/bootstrap/issues/12359
-  // and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements
-  min-width: 0;
-  // Reset the default outline behavior of fieldsets so they don't affect page layout.
-  padding: 0;
-  margin: 0;
-  border: 0;
-}
-
-// 1. Correct the text wrapping in Edge and IE.
-// 2. Correct the color inheritance from `fieldset` elements in IE.
-legend {
-  display: block;
-  width: 100%;
-  max-width: 100%; // 1
-  padding: 0;
-  margin-bottom: .5rem;
-  font-size: 1.5rem;
-  line-height: inherit;
-  color: inherit; // 2
-  white-space: normal; // 1
-}
-
-progress {
-  vertical-align: baseline; // Add the correct vertical alignment in Chrome, Firefox, and Opera.
-}
-
-// Correct the cursor style of increment and decrement buttons in Chrome.
-[type="number"]::-webkit-inner-spin-button,
-[type="number"]::-webkit-outer-spin-button {
-  height: auto;
-}
-
-[type="search"] {
-  // This overrides the extra rounded corners on search inputs in iOS so that our
-  // `.form-control` class can properly style them. Note that this cannot simply
-  // be added to `.form-control` as it's not specific enough. For details, see
-  // https://github.com/twbs/bootstrap/issues/11586.
-  outline-offset: -2px; // 2. Correct the outline style in Safari.
-  -webkit-appearance: none;
-}
-
-//
-// Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
-//
-
-[type="search"]::-webkit-search-cancel-button,
-[type="search"]::-webkit-search-decoration {
-  -webkit-appearance: none;
-}
-
-//
-// 1. Correct the inability to style clickable types in iOS and Safari.
-// 2. Change font properties to `inherit` in Safari.
-//
-
-::-webkit-file-upload-button {
-  font: inherit; // 2
-  -webkit-appearance: button; // 1
-}
-
-//
-// Correct element displays
-//
-
-output {
-  display: inline-block;
-}
-
-summary {
-  display: list-item; // Add the correct display in all browsers
-  cursor: pointer;
-}
-
-template {
-  display: none; // Add the correct display in IE
-}
-
-// Always hide an element with the `hidden` HTML attribute (from PureCSS).
-// Needed for proper display in IE 10-.
-[hidden] {
-  display: none !important;
-}
+// stylelint-disable at-rule-no-vendor-prefix, declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix
+
+// Reboot
+//
+// Normalization of HTML elements, manually forked from Normalize.css to remove
+// styles targeting irrelevant browsers while applying new styles.
+//
+// Normalize is licensed MIT. https://github.com/necolas/normalize.css
+
+
+// Document
+//
+// 1. Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.
+// 2. Change the default font family in all browsers.
+// 3. Correct the line height in all browsers.
+// 4. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS.
+// 5. Setting @viewport causes scrollbars to overlap content in IE11 and Edge, so
+//    we force a non-overlapping, non-auto-hiding scrollbar to counteract.
+// 6. Change the default tap highlight to be completely transparent in iOS.
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box; // 1
+}
+
+html {
+  font-family: sans-serif; // 2
+  line-height: 1.15; // 3
+  -webkit-text-size-adjust: 100%; // 4
+  -ms-text-size-adjust: 100%; // 4
+  -ms-overflow-style: scrollbar; // 5
+  -webkit-tap-highlight-color: rgba($black, 0); // 6
+}
+
+// IE10+ doesn't honor `<meta name="viewport">` in some cases.
+@at-root {
+  @-ms-viewport {
+    width: device-width;
+  }
+}
+
+// stylelint-disable selector-list-comma-newline-after
+// Shim for "new" HTML5 structural elements to display correctly (IE10, older browsers)
+article, aside, figcaption, figure, footer, header, hgroup, main, nav, section {
+  display: block;
+}
+// stylelint-enable selector-list-comma-newline-after
+
+// Body
+//
+// 1. Remove the margin in all browsers.
+// 2. As a best practice, apply a default `background-color`.
+// 3. Set an explicit initial text-align value so that we can later use the
+//    the `inherit` value on things like `<th>` elements.
+
+body {
+  margin: 0; // 1
+  font-family: $font-family-base;
+  font-size: $font-size-base;
+  font-weight: $font-weight-base;
+  line-height: $line-height-base;
+  color: $body-color;
+  text-align: left; // 3
+  background-color: $body-bg; // 2
+}
+
+// Suppress the focus outline on elements that cannot be accessed via keyboard.
+// This prevents an unwanted focus outline from appearing around elements that
+// might still respond to pointer events.
+//
+// Credit: https://github.com/suitcss/base
+[tabindex="-1"]:focus {
+  outline: 0 !important;
+}
+
+
+// Content grouping
+//
+// 1. Add the correct box sizing in Firefox.
+// 2. Show the overflow in Edge and IE.
+
+hr {
+  box-sizing: content-box; // 1
+  height: 0; // 1
+  overflow: visible; // 2
+}
+
+
+//
+// Typography
+//
+
+// Remove top margins from headings
+//
+// By default, `<h1>`-`<h6>` all receive top and bottom margins. We nuke the top
+// margin for easier control within type scales as it avoids margin collapsing.
+// stylelint-disable selector-list-comma-newline-after
+h1, h2, h3, h4, h5, h6 {
+  margin-top: 0;
+  margin-bottom: $headings-margin-bottom;
+}
+// stylelint-enable selector-list-comma-newline-after
+
+// Reset margins on paragraphs
+//
+// Similarly, the top margin on `<p>`s get reset. However, we also reset the
+// bottom margin to use `rem` units instead of `em`.
+p {
+  margin-top: 0;
+  margin-bottom: $paragraph-margin-bottom;
+}
+
+// Abbreviations
+//
+// 1. Remove the bottom border in Firefox 39-.
+// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+// 3. Add explicit cursor to indicate changed behavior.
+// 4. Duplicate behavior to the data-* attribute for our tooltip plugin
+
+abbr[title],
+abbr[data-original-title] { // 4
+  text-decoration: underline; // 2
+  text-decoration: underline dotted; // 2
+  cursor: help; // 3
+  border-bottom: 0; // 1
+}
+
+address {
+  margin-bottom: 1rem;
+  font-style: normal;
+  line-height: inherit;
+}
+
+ol,
+ul,
+dl {
+  margin-top: 0;
+  margin-bottom: 1rem;
+}
+
+ol ol,
+ul ul,
+ol ul,
+ul ol {
+  margin-bottom: 0;
+}
+
+dt {
+  font-weight: $dt-font-weight;
+}
+
+dd {
+  margin-bottom: .5rem;
+  margin-left: 0; // Undo browser default
+}
+
+blockquote {
+  margin: 0 0 1rem;
+}
+
+dfn {
+  font-style: italic; // Add the correct font style in Android 4.3-
+}
+
+// stylelint-disable font-weight-notation
+b,
+strong {
+  font-weight: bolder; // Add the correct font weight in Chrome, Edge, and Safari
+}
+// stylelint-enable font-weight-notation
+
+small {
+  font-size: 80%; // Add the correct font size in all browsers
+}
+
+//
+// Prevent `sub` and `sup` elements from affecting the line height in
+// all browsers.
+//
+
+sub,
+sup {
+  position: relative;
+  font-size: 75%;
+  line-height: 0;
+  vertical-align: baseline;
+}
+
+sub { bottom: -.25em; }
+sup { top: -.5em; }
+
+
+//
+// Links
+//
+
+a {
+  color: $link-color;
+  text-decoration: $link-decoration;
+  background-color: transparent; // Remove the gray background on active links in IE 10.
+  -webkit-text-decoration-skip: objects; // Remove gaps in links underline in iOS 8+ and Safari 8+.
+
+  @include hover {
+    color: $link-hover-color;
+    text-decoration: $link-hover-decoration;
+  }
+}
+
+// And undo these styles for placeholder links/named anchors (without href)
+// which have not been made explicitly keyboard-focusable (without tabindex).
+// It would be more straightforward to just use a[href] in previous block, but that
+// causes specificity issues in many other styles that are too complex to fix.
+// See https://github.com/twbs/bootstrap/issues/19402
+
+a:not([href]):not([tabindex]) {
+  color: inherit;
+  text-decoration: none;
+
+  @include hover-focus {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  &:focus {
+    outline: 0;
+  }
+}
+
+
+//
+// Code
+//
+
+pre,
+code,
+kbd,
+samp {
+  font-family: $font-family-monospace;
+  font-size: 1em; // Correct the odd `em` font sizing in all browsers.
+}
+
+pre {
+  // Remove browser default top margin
+  margin-top: 0;
+  // Reset browser default of `1em` to use `rem`s
+  margin-bottom: 1rem;
+  // Don't allow content to break outside
+  overflow: auto;
+  // We have @viewport set which causes scrollbars to overlap content in IE11 and Edge, so
+  // we force a non-overlapping, non-auto-hiding scrollbar to counteract.
+  -ms-overflow-style: scrollbar;
+}
+
+
+//
+// Figures
+//
+
+figure {
+  // Apply a consistent margin strategy (matches our type styles).
+  margin: 0 0 1rem;
+}
+
+
+//
+// Images and content
+//
+
+img {
+  vertical-align: middle;
+  border-style: none; // Remove the border on images inside links in IE 10-.
+}
+
+svg {
+  // Workaround for the SVG overflow bug in IE10/11 is still required.
+  // See https://github.com/twbs/bootstrap/issues/26878
+  overflow: hidden;
+  vertical-align: middle;
+}
+
+
+//
+// Tables
+//
+
+table {
+  border-collapse: collapse; // Prevent double borders
+}
+
+caption {
+  padding-top: $table-cell-padding;
+  padding-bottom: $table-cell-padding;
+  color: $table-caption-color;
+  text-align: left;
+  caption-side: bottom;
+}
+
+th {
+  // Matches default `<td>` alignment by inheriting from the `<body>`, or the
+  // closest parent with a set `text-align`.
+  text-align: inherit;
+}
+
+
+//
+// Forms
+//
+
+label {
+  // Allow labels to use `margin` for spacing.
+  display: inline-block;
+  margin-bottom: $label-margin-bottom;
+}
+
+// Remove the default `border-radius` that macOS Chrome adds.
+//
+// Details at https://github.com/twbs/bootstrap/issues/24093
+button {
+  border-radius: 0;
+}
+
+// Work around a Firefox/IE bug where the transparent `button` background
+// results in a loss of the default `button` focus styles.
+//
+// Credit: https://github.com/suitcss/base/
+button:focus {
+  outline: 1px dotted;
+  outline: 5px auto -webkit-focus-ring-color;
+}
+
+input,
+button,
+select,
+optgroup,
+textarea {
+  margin: 0; // Remove the margin in Firefox and Safari
+  font-family: inherit;
+  font-size: inherit;
+  line-height: inherit;
+}
+
+button,
+input {
+  overflow: visible; // Show the overflow in Edge
+}
+
+button,
+select {
+  text-transform: none; // Remove the inheritance of text transform in Firefox
+}
+
+// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
+//    controls in Android 4.
+// 2. Correct the inability to style clickable types in iOS and Safari.
+button,
+html [type="button"], // 1
+[type="reset"],
+[type="submit"] {
+  -webkit-appearance: button; // 2
+}
+
+// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+  padding: 0;
+  border-style: none;
+}
+
+input[type="radio"],
+input[type="checkbox"] {
+  box-sizing: border-box; // 1. Add the correct box sizing in IE 10-
+  padding: 0; // 2. Remove the padding in IE 10-
+}
+
+
+input[type="date"],
+input[type="time"],
+input[type="datetime-local"],
+input[type="month"] {
+  // Remove the default appearance of temporal inputs to avoid a Mobile Safari
+  // bug where setting a custom line-height prevents text from being vertically
+  // centered within the input.
+  // See https://bugs.webkit.org/show_bug.cgi?id=139848
+  // and https://github.com/twbs/bootstrap/issues/11266
+  -webkit-appearance: listbox;
+}
+
+textarea {
+  overflow: auto; // Remove the default vertical scrollbar in IE.
+  // Textareas should really only resize vertically so they don't break their (horizontal) containers.
+  resize: vertical;
+}
+
+fieldset {
+  // Browsers set a default `min-width: min-content;` on fieldsets,
+  // unlike e.g. `<div>`s, which have `min-width: 0;` by default.
+  // So we reset that to ensure fieldsets behave more like a standard block element.
+  // See https://github.com/twbs/bootstrap/issues/12359
+  // and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements
+  min-width: 0;
+  // Reset the default outline behavior of fieldsets so they don't affect page layout.
+  padding: 0;
+  margin: 0;
+  border: 0;
+}
+
+// 1. Correct the text wrapping in Edge and IE.
+// 2. Correct the color inheritance from `fieldset` elements in IE.
+legend {
+  display: block;
+  width: 100%;
+  max-width: 100%; // 1
+  padding: 0;
+  margin-bottom: .5rem;
+  font-size: 1.5rem;
+  line-height: inherit;
+  color: inherit; // 2
+  white-space: normal; // 1
+}
+
+progress {
+  vertical-align: baseline; // Add the correct vertical alignment in Chrome, Firefox, and Opera.
+}
+
+// Correct the cursor style of increment and decrement buttons in Chrome.
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+  height: auto;
+}
+
+[type="search"] {
+  // This overrides the extra rounded corners on search inputs in iOS so that our
+  // `.form-control` class can properly style them. Note that this cannot simply
+  // be added to `.form-control` as it's not specific enough. For details, see
+  // https://github.com/twbs/bootstrap/issues/11586.
+  outline-offset: -2px; // 2. Correct the outline style in Safari.
+  -webkit-appearance: none;
+}
+
+//
+// Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
+//
+
+[type="search"]::-webkit-search-cancel-button,
+[type="search"]::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+//
+// 1. Correct the inability to style clickable types in iOS and Safari.
+// 2. Change font properties to `inherit` in Safari.
+//
+
+::-webkit-file-upload-button {
+  font: inherit; // 2
+  -webkit-appearance: button; // 1
+}
+
+//
+// Correct element displays
+//
+
+output {
+  display: inline-block;
+}
+
+summary {
+  display: list-item; // Add the correct display in all browsers
+  cursor: pointer;
+}
+
+template {
+  display: none; // Add the correct display in IE
+}
+
+// Always hide an element with the `hidden` HTML attribute (from PureCSS).
+// Needed for proper display in IE 10-.
+[hidden] {
+  display: none !important;
+}

+ 19 - 19
src/client/styles/bootstrap4/_root.scss

@@ -1,19 +1,19 @@
-:root {
-  // Custom variable values only support SassScript inside `#{}`.
-  @each $color, $value in $colors {
-    --#{$color}: #{$value};
-  }
-
-  @each $color, $value in $theme-colors {
-    --#{$color}: #{$value};
-  }
-
-  @each $bp, $value in $grid-breakpoints {
-    --breakpoint-#{$bp}: #{$value};
-  }
-
-  // Use `inspect` for lists so that quoted items keep the quotes.
-  // See https://github.com/sass/sass/issues/2383#issuecomment-336349172
-  --font-family-sans-serif: #{inspect($font-family-sans-serif)};
-  --font-family-monospace: #{inspect($font-family-monospace)};
-}
+:root {
+  // Custom variable values only support SassScript inside `#{}`.
+  @each $color, $value in $colors {
+    --#{$color}: #{$value};
+  }
+
+  @each $color, $value in $theme-colors {
+    --#{$color}: #{$value};
+  }
+
+  @each $bp, $value in $grid-breakpoints {
+    --breakpoint-#{$bp}: #{$value};
+  }
+
+  // Use `inspect` for lists so that quoted items keep the quotes.
+  // See https://github.com/sass/sass/issues/2383#issuecomment-336349172
+  --font-family-sans-serif: #{inspect($font-family-sans-serif)};
+  --font-family-monospace: #{inspect($font-family-monospace)};
+}

+ 187 - 180
src/client/styles/bootstrap4/_tables.scss

@@ -1,180 +1,187 @@
-//
-// Basic Bootstrap table
-//
-
-.table {
-  width: 100%;
-  max-width: 100%;
-  margin-bottom: $spacer;
-  background-color: $table-bg; // Reset for nesting within parents with `background-color`.
-
-  th,
-  td {
-    padding: $table-cell-padding;
-    vertical-align: top;
-    border-top: $table-border-width solid $table-border-color;
-  }
-
-  thead th {
-    vertical-align: bottom;
-    border-bottom: (2 * $table-border-width) solid $table-border-color;
-  }
-
-  tbody + tbody {
-    border-top: (2 * $table-border-width) solid $table-border-color;
-  }
-
-  .table {
-    background-color: $body-bg;
-  }
-}
-
-
-//
-// Condensed table w/ half padding
-//
-
-.table-sm {
-  th,
-  td {
-    padding: $table-cell-padding-sm;
-  }
-}
-
-
-// Bordered version
-//
-// Add borders all around the table and between all the columns.
-
-.table-bordered {
-  border: $table-border-width solid $table-border-color;
-
-  th,
-  td {
-    border: $table-border-width solid $table-border-color;
-  }
-
-  thead {
-    th,
-    td {
-      border-bottom-width: (2 * $table-border-width);
-    }
-  }
-}
-
-
-// Zebra-striping
-//
-// Default zebra-stripe styles (alternating gray and transparent backgrounds)
-
-.table-striped {
-  tbody tr:nth-of-type(odd) {
-    background-color: $table-accent-bg;
-  }
-}
-
-
-// Hover effect
-//
-// Placed here since it has to come after the potential zebra striping
-
-.table-hover {
-  tbody tr {
-    @include hover {
-      background-color: $table-hover-bg;
-    }
-  }
-}
-
-
-// Table backgrounds
-//
-// Exact selectors below required to override `.table-striped` and prevent
-// inheritance to nested tables.
-
-@each $color, $value in $theme-colors {
-  @include table-row-variant($color, theme-color-level($color, -9));
-}
-
-@include table-row-variant(active, $table-active-bg);
-
-
-// Dark styles
-//
-// Same table markup, but inverted color scheme: dark background and light text.
-
-// stylelint-disable-next-line no-duplicate-selectors
-.table {
-  .thead-dark {
-    th {
-      color: $table-dark-color;
-      background-color: $table-dark-bg;
-      border-color: $table-dark-border-color;
-    }
-  }
-
-  .thead-light {
-    th {
-      color: $table-head-color;
-      background-color: $table-head-bg;
-      border-color: $table-border-color;
-    }
-  }
-}
-
-.table-dark {
-  color: $table-dark-color;
-  background-color: $table-dark-bg;
-
-  th,
-  td,
-  thead th {
-    border-color: $table-dark-border-color;
-  }
-
-  &.table-bordered {
-    border: 0;
-  }
-
-  &.table-striped {
-    tbody tr:nth-of-type(odd) {
-      background-color: $table-dark-accent-bg;
-    }
-  }
-
-  &.table-hover {
-    tbody tr {
-      @include hover {
-        background-color: $table-dark-hover-bg;
-      }
-    }
-  }
-}
-
-
-// Responsive tables
-//
-// Generate series of `.table-responsive-*` classes for configuring the screen
-// size of where your table will overflow.
-
-.table-responsive {
-  @each $breakpoint in map-keys($grid-breakpoints) {
-    $next: breakpoint-next($breakpoint, $grid-breakpoints);
-    $infix: breakpoint-infix($next, $grid-breakpoints);
-
-    &#{$infix} {
-      @include media-breakpoint-down($breakpoint) {
-        display: block;
-        width: 100%;
-        overflow-x: auto;
-        -webkit-overflow-scrolling: touch;
-        -ms-overflow-style: -ms-autohiding-scrollbar; // See https://github.com/twbs/bootstrap/pull/10057
-
-        // Prevent double border on horizontal scroll due to use of `display: block;`
-        > .table-bordered {
-          border: 0;
-        }
-      }
-    }
-  }
-}
+//
+// Basic Bootstrap table
+//
+
+.table {
+  width: 100%;
+  margin-bottom: $spacer;
+  background-color: $table-bg; // Reset for nesting within parents with `background-color`.
+
+  th,
+  td {
+    padding: $table-cell-padding;
+    vertical-align: top;
+    border-top: $table-border-width solid $table-border-color;
+  }
+
+  thead th {
+    vertical-align: bottom;
+    border-bottom: (2 * $table-border-width) solid $table-border-color;
+  }
+
+  tbody + tbody {
+    border-top: (2 * $table-border-width) solid $table-border-color;
+  }
+
+  .table {
+    background-color: $body-bg;
+  }
+}
+
+
+//
+// Condensed table w/ half padding
+//
+
+.table-sm {
+  th,
+  td {
+    padding: $table-cell-padding-sm;
+  }
+}
+
+
+// Border versions
+//
+// Add or remove borders all around the table and between all the columns.
+
+.table-bordered {
+  border: $table-border-width solid $table-border-color;
+
+  th,
+  td {
+    border: $table-border-width solid $table-border-color;
+  }
+
+  thead {
+    th,
+    td {
+      border-bottom-width: (2 * $table-border-width);
+    }
+  }
+}
+
+.table-borderless {
+  th,
+  td,
+  thead th,
+  tbody + tbody {
+    border: 0;
+  }
+}
+
+// Zebra-striping
+//
+// Default zebra-stripe styles (alternating gray and transparent backgrounds)
+
+.table-striped {
+  tbody tr:nth-of-type(#{$table-striped-order}) {
+    background-color: $table-accent-bg;
+  }
+}
+
+
+// Hover effect
+//
+// Placed here since it has to come after the potential zebra striping
+
+.table-hover {
+  tbody tr {
+    @include hover {
+      background-color: $table-hover-bg;
+    }
+  }
+}
+
+
+// Table backgrounds
+//
+// Exact selectors below required to override `.table-striped` and prevent
+// inheritance to nested tables.
+
+@each $color, $value in $theme-colors {
+  @include table-row-variant($color, theme-color-level($color, -9));
+}
+
+@include table-row-variant(active, $table-active-bg);
+
+
+// Dark styles
+//
+// Same table markup, but inverted color scheme: dark background and light text.
+
+// stylelint-disable-next-line no-duplicate-selectors
+.table {
+  .thead-dark {
+    th {
+      color: $table-dark-color;
+      background-color: $table-dark-bg;
+      border-color: $table-dark-border-color;
+    }
+  }
+
+  .thead-light {
+    th {
+      color: $table-head-color;
+      background-color: $table-head-bg;
+      border-color: $table-border-color;
+    }
+  }
+}
+
+.table-dark {
+  color: $table-dark-color;
+  background-color: $table-dark-bg;
+
+  th,
+  td,
+  thead th {
+    border-color: $table-dark-border-color;
+  }
+
+  &.table-bordered {
+    border: 0;
+  }
+
+  &.table-striped {
+    tbody tr:nth-of-type(odd) {
+      background-color: $table-dark-accent-bg;
+    }
+  }
+
+  &.table-hover {
+    tbody tr {
+      @include hover {
+        background-color: $table-dark-hover-bg;
+      }
+    }
+  }
+}
+
+
+// Responsive tables
+//
+// Generate series of `.table-responsive-*` classes for configuring the screen
+// size of where your table will overflow.
+
+.table-responsive {
+  @each $breakpoint in map-keys($grid-breakpoints) {
+    $next: breakpoint-next($breakpoint, $grid-breakpoints);
+    $infix: breakpoint-infix($next, $grid-breakpoints);
+
+    &#{$infix} {
+      @include media-breakpoint-down($breakpoint) {
+        display: block;
+        width: 100%;
+        overflow-x: auto;
+        -webkit-overflow-scrolling: touch;
+        -ms-overflow-style: -ms-autohiding-scrollbar; // See https://github.com/twbs/bootstrap/pull/10057
+
+        // Prevent double border on horizontal scroll due to use of `display: block;`
+        > .table-bordered {
+          border: 0;
+        }
+      }
+    }
+  }
+}

+ 115 - 115
src/client/styles/bootstrap4/_tooltip.scss

@@ -1,115 +1,115 @@
-// Base class
-.tooltip {
-  position: absolute;
-  z-index: $zindex-tooltip;
-  display: block;
-  margin: $tooltip-margin;
-  // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
-  // So reset our font and text properties to avoid inheriting weird values.
-  @include reset-text();
-  font-size: $tooltip-font-size;
-  // Allow breaking very long words so they don't overflow the tooltip's bounds
-  word-wrap: break-word;
-  opacity: 0;
-
-  &.show { opacity: $tooltip-opacity; }
-
-  .arrow {
-    position: absolute;
-    display: block;
-    width: $tooltip-arrow-width;
-    height: $tooltip-arrow-height;
-
-    &::before {
-      position: absolute;
-      content: "";
-      border-color: transparent;
-      border-style: solid;
-    }
-  }
-}
-
-.bs-tooltip-top {
-  padding: $tooltip-arrow-height 0;
-
-  .arrow {
-    bottom: 0;
-
-    &::before {
-      top: 0;
-      border-width: $tooltip-arrow-height ($tooltip-arrow-width / 2) 0;
-      border-top-color: $tooltip-arrow-color;
-    }
-  }
-}
-
-.bs-tooltip-right {
-  padding: 0 $tooltip-arrow-height;
-
-  .arrow {
-    left: 0;
-    width: $tooltip-arrow-height;
-    height: $tooltip-arrow-width;
-
-    &::before {
-      right: 0;
-      border-width: ($tooltip-arrow-width / 2) $tooltip-arrow-height ($tooltip-arrow-width / 2) 0;
-      border-right-color: $tooltip-arrow-color;
-    }
-  }
-}
-
-.bs-tooltip-bottom {
-  padding: $tooltip-arrow-height 0;
-
-  .arrow {
-    top: 0;
-
-    &::before {
-      bottom: 0;
-      border-width: 0 ($tooltip-arrow-width / 2) $tooltip-arrow-height;
-      border-bottom-color: $tooltip-arrow-color;
-    }
-  }
-}
-
-.bs-tooltip-left {
-  padding: 0 $tooltip-arrow-height;
-
-  .arrow {
-    right: 0;
-    width: $tooltip-arrow-height;
-    height: $tooltip-arrow-width;
-
-    &::before {
-      left: 0;
-      border-width: ($tooltip-arrow-width / 2) 0 ($tooltip-arrow-width / 2) $tooltip-arrow-height;
-      border-left-color: $tooltip-arrow-color;
-    }
-  }
-}
-
-.bs-tooltip-auto {
-  &[x-placement^="top"] {
-    @extend .bs-tooltip-top;
-  }
-  &[x-placement^="right"] {
-    @extend .bs-tooltip-right;
-  }
-  &[x-placement^="bottom"] {
-    @extend .bs-tooltip-bottom;
-  }
-  &[x-placement^="left"] {
-    @extend .bs-tooltip-left;
-  }
-}
-
-// Wrapper for the tooltip content
-.tooltip-inner {
-  max-width: $tooltip-max-width;
-  padding: $tooltip-padding-y $tooltip-padding-x;
-  color: $tooltip-color;
-  text-align: center;
-  background-color: $tooltip-bg;
-  @include border-radius($tooltip-border-radius);
-}
+// Base class
+.tooltip {
+  position: absolute;
+  z-index: $zindex-tooltip;
+  display: block;
+  margin: $tooltip-margin;
+  // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
+  // So reset our font and text properties to avoid inheriting weird values.
+  @include reset-text();
+  font-size: $tooltip-font-size;
+  // Allow breaking very long words so they don't overflow the tooltip's bounds
+  word-wrap: break-word;
+  opacity: 0;
+
+  &.show { opacity: $tooltip-opacity; }
+
+  .arrow {
+    position: absolute;
+    display: block;
+    width: $tooltip-arrow-width;
+    height: $tooltip-arrow-height;
+
+    &::before {
+      position: absolute;
+      content: "";
+      border-color: transparent;
+      border-style: solid;
+    }
+  }
+}
+
+.bs-tooltip-top {
+  padding: $tooltip-arrow-height 0;
+
+  .arrow {
+    bottom: 0;
+
+    &::before {
+      top: 0;
+      border-width: $tooltip-arrow-height ($tooltip-arrow-width / 2) 0;
+      border-top-color: $tooltip-arrow-color;
+    }
+  }
+}
+
+.bs-tooltip-right {
+  padding: 0 $tooltip-arrow-height;
+
+  .arrow {
+    left: 0;
+    width: $tooltip-arrow-height;
+    height: $tooltip-arrow-width;
+
+    &::before {
+      right: 0;
+      border-width: ($tooltip-arrow-width / 2) $tooltip-arrow-height ($tooltip-arrow-width / 2) 0;
+      border-right-color: $tooltip-arrow-color;
+    }
+  }
+}
+
+.bs-tooltip-bottom {
+  padding: $tooltip-arrow-height 0;
+
+  .arrow {
+    top: 0;
+
+    &::before {
+      bottom: 0;
+      border-width: 0 ($tooltip-arrow-width / 2) $tooltip-arrow-height;
+      border-bottom-color: $tooltip-arrow-color;
+    }
+  }
+}
+
+.bs-tooltip-left {
+  padding: 0 $tooltip-arrow-height;
+
+  .arrow {
+    right: 0;
+    width: $tooltip-arrow-height;
+    height: $tooltip-arrow-width;
+
+    &::before {
+      left: 0;
+      border-width: ($tooltip-arrow-width / 2) 0 ($tooltip-arrow-width / 2) $tooltip-arrow-height;
+      border-left-color: $tooltip-arrow-color;
+    }
+  }
+}
+
+.bs-tooltip-auto {
+  &[x-placement^="top"] {
+    @extend .bs-tooltip-top;
+  }
+  &[x-placement^="right"] {
+    @extend .bs-tooltip-right;
+  }
+  &[x-placement^="bottom"] {
+    @extend .bs-tooltip-bottom;
+  }
+  &[x-placement^="left"] {
+    @extend .bs-tooltip-left;
+  }
+}
+
+// Wrapper for the tooltip content
+.tooltip-inner {
+  max-width: $tooltip-max-width;
+  padding: $tooltip-padding-y $tooltip-padding-x;
+  color: $tooltip-color;
+  text-align: center;
+  background-color: $tooltip-bg;
+  @include border-radius($tooltip-border-radius);
+}

+ 22 - 36
src/client/styles/bootstrap4/_transitions.scss

@@ -1,36 +1,22 @@
-// stylelint-disable selector-no-qualifying-type
-
-.fade {
-  opacity: 0;
-  @include transition($transition-fade);
-
-  &.show {
-    opacity: 1;
-  }
-}
-
-.collapse {
-  display: none;
-  &.show {
-    display: block;
-  }
-}
-
-tr {
-  &.collapse.show {
-    display: table-row;
-  }
-}
-
-tbody {
-  &.collapse.show {
-    display: table-row-group;
-  }
-}
-
-.collapsing {
-  position: relative;
-  height: 0;
-  overflow: hidden;
-  @include transition($transition-collapse);
-}
+// stylelint-disable selector-no-qualifying-type
+
+.fade {
+  @include transition($transition-fade);
+
+  &:not(.show) {
+    opacity: 0;
+  }
+}
+
+.collapse {
+  &:not(.show) {
+    display: none;
+  }
+}
+
+.collapsing {
+  position: relative;
+  height: 0;
+  overflow: hidden;
+  @include transition($transition-collapse);
+}

+ 125 - 125
src/client/styles/bootstrap4/_type.scss

@@ -1,125 +1,125 @@
-// stylelint-disable declaration-no-important, selector-list-comma-newline-after
-
-//
-// Headings
-//
-
-h1, h2, h3, h4, h5, h6,
-.h1, .h2, .h3, .h4, .h5, .h6 {
-  margin-bottom: $headings-margin-bottom;
-  font-family: $headings-font-family;
-  font-weight: $headings-font-weight;
-  line-height: $headings-line-height;
-  color: $headings-color;
-}
-
-h1, .h1 { font-size: $h1-font-size; }
-h2, .h2 { font-size: $h2-font-size; }
-h3, .h3 { font-size: $h3-font-size; }
-h4, .h4 { font-size: $h4-font-size; }
-h5, .h5 { font-size: $h5-font-size; }
-h6, .h6 { font-size: $h6-font-size; }
-
-.lead {
-  font-size: $lead-font-size;
-  font-weight: $lead-font-weight;
-}
-
-// Type display classes
-.display-1 {
-  font-size: $display1-size;
-  font-weight: $display1-weight;
-  line-height: $display-line-height;
-}
-.display-2 {
-  font-size: $display2-size;
-  font-weight: $display2-weight;
-  line-height: $display-line-height;
-}
-.display-3 {
-  font-size: $display3-size;
-  font-weight: $display3-weight;
-  line-height: $display-line-height;
-}
-.display-4 {
-  font-size: $display4-size;
-  font-weight: $display4-weight;
-  line-height: $display-line-height;
-}
-
-
-//
-// Horizontal rules
-//
-
-hr {
-  margin-top: $hr-margin-y;
-  margin-bottom: $hr-margin-y;
-  border: 0;
-  border-top: $hr-border-width solid $hr-border-color;
-}
-
-
-//
-// Emphasis
-//
-
-small,
-.small {
-  font-size: $small-font-size;
-  font-weight: $font-weight-normal;
-}
-
-mark,
-.mark {
-  padding: $mark-padding;
-  background-color: $mark-bg;
-}
-
-
-//
-// Lists
-//
-
-.list-unstyled {
-  @include list-unstyled;
-}
-
-// Inline turns list items into inline-block
-.list-inline {
-  @include list-unstyled;
-}
-.list-inline-item {
-  display: inline-block;
-
-  &:not(:last-child) {
-    margin-right: $list-inline-padding;
-  }
-}
-
-
-//
-// Misc
-//
-
-// Builds on `abbr`
-.initialism {
-  font-size: 90%;
-  text-transform: uppercase;
-}
-
-// Blockquotes
-.blockquote {
-  margin-bottom: $spacer;
-  font-size: $blockquote-font-size;
-}
-
-.blockquote-footer {
-  display: block;
-  font-size: 80%; // back to default font-size
-  color: $blockquote-small-color;
-
-  &::before {
-    content: "\2014 \00A0"; // em dash, nbsp
-  }
-}
+// stylelint-disable declaration-no-important, selector-list-comma-newline-after
+
+//
+// Headings
+//
+
+h1, h2, h3, h4, h5, h6,
+.h1, .h2, .h3, .h4, .h5, .h6 {
+  margin-bottom: $headings-margin-bottom;
+  font-family: $headings-font-family;
+  font-weight: $headings-font-weight;
+  line-height: $headings-line-height;
+  color: $headings-color;
+}
+
+h1, .h1 { font-size: $h1-font-size; }
+h2, .h2 { font-size: $h2-font-size; }
+h3, .h3 { font-size: $h3-font-size; }
+h4, .h4 { font-size: $h4-font-size; }
+h5, .h5 { font-size: $h5-font-size; }
+h6, .h6 { font-size: $h6-font-size; }
+
+.lead {
+  font-size: $lead-font-size;
+  font-weight: $lead-font-weight;
+}
+
+// Type display classes
+.display-1 {
+  font-size: $display1-size;
+  font-weight: $display1-weight;
+  line-height: $display-line-height;
+}
+.display-2 {
+  font-size: $display2-size;
+  font-weight: $display2-weight;
+  line-height: $display-line-height;
+}
+.display-3 {
+  font-size: $display3-size;
+  font-weight: $display3-weight;
+  line-height: $display-line-height;
+}
+.display-4 {
+  font-size: $display4-size;
+  font-weight: $display4-weight;
+  line-height: $display-line-height;
+}
+
+
+//
+// Horizontal rules
+//
+
+hr {
+  margin-top: $hr-margin-y;
+  margin-bottom: $hr-margin-y;
+  border: 0;
+  border-top: $hr-border-width solid $hr-border-color;
+}
+
+
+//
+// Emphasis
+//
+
+small,
+.small {
+  font-size: $small-font-size;
+  font-weight: $font-weight-normal;
+}
+
+mark,
+.mark {
+  padding: $mark-padding;
+  background-color: $mark-bg;
+}
+
+
+//
+// Lists
+//
+
+.list-unstyled {
+  @include list-unstyled;
+}
+
+// Inline turns list items into inline-block
+.list-inline {
+  @include list-unstyled;
+}
+.list-inline-item {
+  display: inline-block;
+
+  &:not(:last-child) {
+    margin-right: $list-inline-padding;
+  }
+}
+
+
+//
+// Misc
+//
+
+// Builds on `abbr`
+.initialism {
+  font-size: 90%;
+  text-transform: uppercase;
+}
+
+// Blockquotes
+.blockquote {
+  margin-bottom: $spacer;
+  font-size: $blockquote-font-size;
+}
+
+.blockquote-footer {
+  display: block;
+  font-size: 80%; // back to default font-size
+  color: $blockquote-small-color;
+
+  &::before {
+    content: "\2014 \00A0"; // em dash, nbsp
+  }
+}

+ 5 - 4
src/client/styles/bootstrap4/_utilities.scss

@@ -2,13 +2,14 @@
 // @import "utilities/background";
 // @import "utilities/borders";
 // @import "utilities/clearfix";
-@import "utilities/display";
+@import 'utilities/display';
 // @import "utilities/embed";
-@import "utilities/flex";
+@import 'utilities/flex';
 // @import "utilities/float";
 // @import "utilities/position";
 // @import "utilities/screenreaders";
+// @import "utilities/shadows";
 // @import "utilities/sizing";
-@import "utilities/spacing";
-@import "utilities/text";
+@import 'utilities/spacing';
+@import 'utilities/text';
 // @import "utilities/visibility";

+ 165 - 122
src/client/styles/bootstrap4/_variables.scss

@@ -7,7 +7,6 @@
 // // Color system
 // //
 
-// // stylelint-disable
 // $white:    #fff !default;
 // $gray-100: #f8f9fa !default;
 // $gray-200: #e9ecef !default;
@@ -21,17 +20,21 @@
 // $black:    #000 !default;
 
 // $grays: () !default;
-// $grays: map-merge((
-//   "100": $gray-100,
-//   "200": $gray-200,
-//   "300": $gray-300,
-//   "400": $gray-400,
-//   "500": $gray-500,
-//   "600": $gray-600,
-//   "700": $gray-700,
-//   "800": $gray-800,
-//   "900": $gray-900
-// ), $grays);
+// // stylelint-disable-next-line scss/dollar-variable-default
+// $grays: map-merge(
+//   (
+//     "100": $gray-100,
+//     "200": $gray-200,
+//     "300": $gray-300,
+//     "400": $gray-400,
+//     "500": $gray-500,
+//     "600": $gray-600,
+//     "700": $gray-700,
+//     "800": $gray-800,
+//     "900": $gray-900
+//   ),
+//   $grays
+// );
 
 // $blue:    #007bff !default;
 // $indigo:  #6610f2 !default;
@@ -45,21 +48,25 @@
 // $cyan:    #17a2b8 !default;
 
 // $colors: () !default;
-// $colors: map-merge((
-//   "blue":       $blue,
-//   "indigo":     $indigo,
-//   "purple":     $purple,
-//   "pink":       $pink,
-//   "red":        $red,
-//   "orange":     $orange,
-//   "yellow":     $yellow,
-//   "green":      $green,
-//   "teal":       $teal,
-//   "cyan":       $cyan,
-//   "white":      $white,
-//   "gray":       $gray-600,
-//   "gray-dark":  $gray-800
-// ), $colors);
+// // stylelint-disable-next-line scss/dollar-variable-default
+// $colors: map-merge(
+//   (
+//     "blue":       $blue,
+//     "indigo":     $indigo,
+//     "purple":     $purple,
+//     "pink":       $pink,
+//     "red":        $red,
+//     "orange":     $orange,
+//     "yellow":     $yellow,
+//     "green":      $green,
+//     "teal":       $teal,
+//     "cyan":       $cyan,
+//     "white":      $white,
+//     "gray":       $gray-600,
+//     "gray-dark":  $gray-800
+//   ),
+//   $colors
+// );
 
 // $primary:       $blue !default;
 // $secondary:     $gray-600 !default;
@@ -71,27 +78,30 @@
 // $dark:          $gray-800 !default;
 
 // $theme-colors: () !default;
-// $theme-colors: map-merge((
-//   "primary":    $primary,
-//   "secondary":  $secondary,
-//   "success":    $success,
-//   "info":       $info,
-//   "warning":    $warning,
-//   "danger":     $danger,
-//   "light":      $light,
-//   "dark":       $dark
-// ), $theme-colors);
-// // stylelint-enable
+// // stylelint-disable-next-line scss/dollar-variable-default
+// $theme-colors: map-merge(
+//   (
+//     "primary":    $primary,
+//     "secondary":  $secondary,
+//     "success":    $success,
+//     "info":       $info,
+//     "warning":    $warning,
+//     "danger":     $danger,
+//     "light":      $light,
+//     "dark":       $dark
+//   ),
+//   $theme-colors
+// );
 
 // // Set a specific jump point for requesting color jumps
 // $theme-color-interval:      8% !default;
 
 // // The yiq lightness value that determines when the lightness of color changes from "dark" to "light". Acceptable values are between 0 and 255.
-// $yiq-contrasted-threshold: 150 !default;
+// $yiq-contrasted-threshold:  150 !default;
 
 // // Customize the light and dark text colors for use in our YIQ color contrast function.
-// $yiq-text-dark: $gray-900 !default;
-// $yiq-text-light: $white !default;
+// $yiq-text-dark:             $gray-900 !default;
+// $yiq-text-light:            $white !default;
 
 // // Options
 // //
@@ -112,27 +122,42 @@
 // variables. Mostly focused on spacing.
 // You can add more entries to the $spacers map, should you need more variation.
 
-// stylelint-disable
 $spacer: 1rem !default;
 $spacers: () !default;
-$spacers: map-merge((
-  0: 0,
-  1: ($spacer * .25),
-  2: ($spacer * .5),
-  3: $spacer,
-  4: ($spacer * 1.5),
-  5: ($spacer * 3)
-), $spacers);
+// stylelint-disable-next-line scss/dollar-variable-default
+$spacers: map-merge(
+  (
+    0: 0,
+    1: (
+      $spacer * 0.25,
+    ),
+    2: (
+      $spacer * 0.5,
+    ),
+    3: $spacer,
+    4: (
+      $spacer * 1.5,
+    ),
+    5: (
+      $spacer * 3,
+    ),
+  ),
+  $spacers
+);
 
 // // This variable affects the `.h-*` and `.w-*` classes.
 // $sizes: () !default;
-// $sizes: map-merge((
-//   25: 25%,
-//   50: 50%,
-//   75: 75%,
-//   100: 100%
-// ), $sizes);
-// // stylelint-enable
+// // stylelint-disable-next-line scss/dollar-variable-default
+// $sizes: map-merge(
+//   (
+//     25: 25%,
+//     50: 50%,
+//     75: 75%,
+//     100: 100%,
+//     auto: auto
+//   ),
+//   $sizes
+// );
 
 // // Body
 // //
@@ -166,10 +191,10 @@ $grid-breakpoints: (
   sm: 576px,
   md: 768px,
   lg: 992px,
-  xl: 1200px
+  xl: 1200px,
 ) !default;
 
-@include _assert-ascending($grid-breakpoints, "$grid-breakpoints");
+@include _assert-ascending($grid-breakpoints, '$grid-breakpoints');
 @include _assert-starts-at-zero($grid-breakpoints);
 
 // // Grid containers
@@ -185,7 +210,6 @@ $grid-breakpoints: (
 
 // @include _assert-ascending($container-max-widths, "$container-max-widths");
 
-
 // // Grid columns
 // //
 // // Set the number of columns and specify the width of the gutters.
@@ -207,6 +231,10 @@ $grid-breakpoints: (
 // $border-radius-lg:            .3rem !default;
 // $border-radius-sm:            .2rem !default;
 
+// $box-shadow-sm:               0 .125rem .25rem rgba($black, .075) !default;
+// $box-shadow:                  0 .5rem 1rem rgba($black, .15) !default;
+// $box-shadow-lg:               0 1rem 3rem rgba($black, .175) !default;
+
 // $component-active-color:      $white !default;
 // $component-active-bg:         theme-color("primary") !default;
 
@@ -216,13 +244,12 @@ $grid-breakpoints: (
 // $transition-fade:             opacity .15s linear !default;
 // $transition-collapse:         height .35s ease !default;
 
-
 // // Fonts
 // //
 // // Font, line-height, and color for body text, headings, and more.
 
 // // stylelint-disable value-keyword-case
-// $font-family-sans-serif:      -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !default;
+// $font-family-sans-serif:      -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default;
 // $font-family-monospace:       SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !default;
 // $font-family-base:            $font-family-sans-serif !default;
 // // stylelint-enable value-keyword-case
@@ -231,9 +258,9 @@ $grid-breakpoints: (
 // $font-size-lg:                ($font-size-base * 1.25) !default;
 // $font-size-sm:                ($font-size-base * .875) !default;
 
-$font-weight-light:           300 !default;
-$font-weight-normal:          400 !default;
-$font-weight-bold:            700 !default;
+$font-weight-light: 300 !default;
+$font-weight-normal: 400 !default;
+$font-weight-bold: 700 !default;
 
 // $font-weight-base:            $font-weight-normal !default;
 // $line-height-base:            1.5 !default;
@@ -288,7 +315,6 @@ $font-weight-bold:            700 !default;
 
 // $hr-margin-y:                 $spacer !default;
 
-
 // // Tables
 // //
 // // Customizes the `.table` component with basic values, each used across all table variations.
@@ -313,6 +339,9 @@ $font-weight-bold:            700 !default;
 // $table-dark-border-color:     lighten($gray-900, 7.5%) !default;
 // $table-dark-color:            $body-bg !default;
 
+// $table-striped-order:         odd !default;
+
+// $table-caption-color:         $text-muted !default;
 
 // // Buttons + Forms
 // //
@@ -336,7 +365,6 @@ $font-weight-bold:            700 !default;
 
 // $input-btn-border-width:      $border-width !default;
 
-
 // // Buttons
 // //
 // // For each of Bootstrap's buttons, define text, background, and border color.
@@ -373,9 +401,10 @@ $font-weight-bold:            700 !default;
 
 // $btn-transition:              color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;
 
-
 // // Forms
 
+// $label-margin-bottom:                   .5rem !default;
+
 // $input-padding-y:                       $input-btn-padding-y !default;
 // $input-padding-x:                       $input-btn-padding-x !default;
 // $input-line-height:                     $input-btn-line-height !default;
@@ -407,6 +436,7 @@ $font-weight-bold:            700 !default;
 // $input-focus-box-shadow:                $input-btn-focus-box-shadow !default;
 
 // $input-placeholder-color:               $gray-600 !default;
+// $input-plaintext-color:                 $body-color !default;
 
 // $input-height-border:                   $input-border-width * 2 !default;
 
@@ -436,6 +466,8 @@ $font-weight-bold:            700 !default;
 // $input-group-addon-bg:                  $gray-200 !default;
 // $input-group-addon-border-color:        $input-border-color !default;
 
+// $custom-forms-transition:               background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;
+
 // $custom-control-gutter:                 1.5rem !default;
 // $custom-control-spacer-x:               1rem !default;
 
@@ -445,7 +477,7 @@ $font-weight-bold:            700 !default;
 // $custom-control-indicator-box-shadow:   inset 0 .25rem .25rem rgba($black, .1) !default;
 
 // $custom-control-indicator-disabled-bg:          $gray-200 !default;
-// $custom-control-label-disabled-color:     $gray-600 !default;
+// $custom-control-label-disabled-color:           $gray-600 !default;
 
 // $custom-control-indicator-checked-color:        $component-active-color !default;
 // $custom-control-indicator-checked-bg:           $component-active-bg !default;
@@ -461,22 +493,22 @@ $font-weight-bold:            700 !default;
 // $custom-checkbox-indicator-border-radius:       $border-radius !default;
 // $custom-checkbox-indicator-icon-checked:        str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='#{$custom-control-indicator-checked-color}' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E"), "#", "%23") !default;
 
-// $custom-checkbox-indicator-indeterminate-bg:    $component-active-bg !default;
-// $custom-checkbox-indicator-indeterminate-color: $custom-control-indicator-checked-color !default;
-// $custom-checkbox-indicator-icon-indeterminate:  str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='#{$custom-checkbox-indicator-indeterminate-color}' d='M0 2h4'/%3E%3C/svg%3E"), "#", "%23") !default;
-// $custom-checkbox-indicator-indeterminate-box-shadow: none !default;
+// $custom-checkbox-indicator-indeterminate-bg:          $component-active-bg !default;
+// $custom-checkbox-indicator-indeterminate-color:       $custom-control-indicator-checked-color !default;
+// $custom-checkbox-indicator-icon-indeterminate:        str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='#{$custom-checkbox-indicator-indeterminate-color}' d='M0 2h4'/%3E%3C/svg%3E"), "#", "%23") !default;
+// $custom-checkbox-indicator-indeterminate-box-shadow:  none !default;
 
 // $custom-radio-indicator-border-radius:          50% !default;
 // $custom-radio-indicator-icon-checked:           str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='#{$custom-control-indicator-checked-color}'/%3E%3C/svg%3E"), "#", "%23") !default;
 
 // $custom-select-padding-y:           .375rem !default;
-// $custom-select-padding-x:          .75rem !default;
+// $custom-select-padding-x:           .75rem !default;
 // $custom-select-height:              $input-height !default;
 // $custom-select-indicator-padding:   1rem !default; // Extra padding to account for the presence of the background-image based indicator
 // $custom-select-line-height:         $input-btn-line-height !default;
 // $custom-select-color:               $input-color !default;
 // $custom-select-disabled-color:      $gray-600 !default;
-// $custom-select-bg:                  $white !default;
+// $custom-select-bg:                  $input-bg !default;
 // $custom-select-disabled-bg:         $gray-200 !default;
 // $custom-select-bg-size:             8px 10px !default; // In pixels because image dimensions
 // $custom-select-indicator-color:     $gray-800 !default;
@@ -484,9 +516,11 @@ $font-weight-bold:            700 !default;
 // $custom-select-border-width:        $input-btn-border-width !default;
 // $custom-select-border-color:        $input-border-color !default;
 // $custom-select-border-radius:       $border-radius !default;
+// $custom-select-box-shadow:          inset 0 1px 2px rgba($black, .075) !default;
 
 // $custom-select-focus-border-color:  $input-focus-border-color !default;
-// $custom-select-focus-box-shadow:    inset 0 1px 2px rgba($black, .075), 0 0 5px rgba($custom-select-focus-border-color, .5) !default;
+// $custom-select-focus-width:         $input-btn-focus-width !default;
+// $custom-select-focus-box-shadow:    0 0 0 $custom-select-focus-width rgba($custom-select-focus-border-color, .5) !default;
 
 // $custom-select-font-size-sm:        75% !default;
 // $custom-select-height-sm:           $input-height-sm !default;
@@ -494,9 +528,28 @@ $font-weight-bold:            700 !default;
 // $custom-select-font-size-lg:        125% !default;
 // $custom-select-height-lg:           $input-height-lg !default;
 
+// $custom-range-track-width:          100% !default;
+// $custom-range-track-height:         .5rem !default;
+// $custom-range-track-cursor:         pointer !default;
+// $custom-range-track-bg:             $gray-300 !default;
+// $custom-range-track-border-radius:  1rem !default;
+// $custom-range-track-box-shadow:     inset 0 .25rem .25rem rgba($black, .1) !default;
+
+// $custom-range-thumb-width:                   1rem !default;
+// $custom-range-thumb-height:                  $custom-range-thumb-width !default;
+// $custom-range-thumb-bg:                      $component-active-bg !default;
+// $custom-range-thumb-border:                  0 !default;
+// $custom-range-thumb-border-radius:           1rem !default;
+// $custom-range-thumb-box-shadow:              0 .1rem .25rem rgba($black, .1) !default;
+// $custom-range-thumb-focus-box-shadow:        0 0 0 1px $body-bg, $input-btn-focus-box-shadow !default;
+// $custom-range-thumb-focus-box-shadow-width:  $input-btn-focus-width !default; // For focus box shadow issue in IE/Edge
+// $custom-range-thumb-active-bg:               lighten($component-active-bg, 35%) !default;
+
 // $custom-file-height:                $input-height !default;
+// $custom-file-height-inner:          $input-height-inner !default;
 // $custom-file-focus-border-color:    $input-focus-border-color !default;
 // $custom-file-focus-box-shadow:      $input-btn-focus-box-shadow !default;
+// $custom-file-disabled-bg:           $input-disabled-bg !default;
 
 // $custom-file-padding-y:             $input-btn-padding-y !default;
 // $custom-file-padding-x:             $input-btn-padding-x !default;
@@ -513,14 +566,12 @@ $font-weight-bold:            700 !default;
 //   en: "Browse"
 // ) !default;
 
-
 // // Form validation
 // $form-feedback-margin-top:          $form-text-margin-top !default;
 // $form-feedback-font-size:           $small-font-size !default;
 // $form-feedback-valid-color:         theme-color("success") !default;
 // $form-feedback-invalid-color:       theme-color("danger") !default;
 
-
 // // Dropdowns
 // //
 // // Dropdown menu container and contents.
@@ -549,7 +600,6 @@ $font-weight-bold:            700 !default;
 
 // $dropdown-header-color:             $gray-600 !default;
 
-
 // // Z-index master list
 // //
 // // Warning: Avoid customizing these values. They're used for a bird's eye view
@@ -581,6 +631,9 @@ $font-weight-bold:            700 !default;
 // $nav-pills-link-active-color:       $component-active-color !default;
 // $nav-pills-link-active-bg:          $component-active-bg !default;
 
+// $nav-divider-color:                 $gray-200 !default;
+// $nav-divider-margin-y:              ($spacer / 2) !default;
+
 // // Navbar
 
 // $navbar-padding-y:                  ($spacer / 2) !default;
@@ -629,6 +682,7 @@ $font-weight-bold:            700 !default;
 // $pagination-border-color:           $gray-300 !default;
 
 // $pagination-focus-box-shadow:       $input-btn-focus-box-shadow !default;
+// $pagination-focus-outline:          0 !default;
 
 // $pagination-hover-color:            $link-hover-color !default;
 // $pagination-hover-bg:               $gray-200 !default;
@@ -642,13 +696,11 @@ $font-weight-bold:            700 !default;
 // $pagination-disabled-bg:            $white !default;
 // $pagination-disabled-border-color:  $gray-300 !default;
 
-
 // // Jumbotron
 
 // $jumbotron-padding:                 2rem !default;
 // $jumbotron-bg:                      $gray-200 !default;
 
-
 // // Cards
 
 // $card-spacer-y:                     .75rem !default;
@@ -669,23 +721,21 @@ $font-weight-bold:            700 !default;
 // $card-columns-gap:                  1.25rem !default;
 // $card-columns-margin:               $card-spacer-y !default;
 
-
 // // Tooltips
 
-// $tooltip-font-size:           $font-size-sm !default;
-// $tooltip-max-width:           200px !default;
-// $tooltip-color:               $white !default;
-// $tooltip-bg:                  $black !default;
-// $tooltip-border-radius:        $border-radius !default;
-// $tooltip-opacity:             .9 !default;
-// $tooltip-padding-y:           .25rem !default;
-// $tooltip-padding-x:           .5rem !default;
-// $tooltip-margin:              0 !default;
-
-// $tooltip-arrow-width:         .8rem !default;
-// $tooltip-arrow-height:        .4rem !default;
-// $tooltip-arrow-color:         $tooltip-bg !default;
+// $tooltip-font-size:                 $font-size-sm !default;
+// $tooltip-max-width:                 200px !default;
+// $tooltip-color:                     $white !default;
+// $tooltip-bg:                        $black !default;
+// $tooltip-border-radius:             $border-radius !default;
+// $tooltip-opacity:                   .9 !default;
+// $tooltip-padding-y:                 .25rem !default;
+// $tooltip-padding-x:                 .5rem !default;
+// $tooltip-margin:                    0 !default;
 
+// $tooltip-arrow-width:               .8rem !default;
+// $tooltip-arrow-height:              .4rem !default;
+// $tooltip-arrow-color:               $tooltip-bg !default;
 
 // // Popovers
 
@@ -712,7 +762,6 @@ $font-weight-bold:            700 !default;
 
 // $popover-arrow-outer-color:         fade-in($popover-border-color, .05) !default;
 
-
 // // Badges
 
 // $badge-font-size:                   75% !default;
@@ -726,30 +775,30 @@ $font-weight-bold:            700 !default;
 // // customizing padding or font-size on labels.
 // $badge-pill-border-radius:          10rem !default;
 
-
 // // Modals
 
 // // Padding applied to the modal body
-// $modal-inner-padding:         1rem !default;
+// $modal-inner-padding:               1rem !default;
 
-// $modal-dialog-margin:         .5rem !default;
-// $modal-dialog-margin-y-sm-up: 1.75rem !default;
+// $modal-dialog-margin:               .5rem !default;
+// $modal-dialog-margin-y-sm-up:       1.75rem !default;
 
 // $modal-title-line-height:           $line-height-base !default;
 
-// $modal-content-bg:               $white !default;
-// $modal-content-border-color:     rgba($black, .2) !default;
-// $modal-content-border-width:     $border-width !default;
-// $modal-content-box-shadow-xs:    0 .25rem .5rem rgba($black, .5) !default;
-// $modal-content-box-shadow-sm-up: 0 .5rem 1rem rgba($black, .5) !default;
-
-// $modal-backdrop-bg:           $black !default;
-// $modal-backdrop-opacity:      .5 !default;
-// $modal-header-border-color:   $gray-200 !default;
-// $modal-footer-border-color:   $modal-header-border-color !default;
-// $modal-header-border-width:   $modal-content-border-width !default;
-// $modal-footer-border-width:   $modal-header-border-width !default;
-// $modal-header-padding:        1rem !default;
+// $modal-content-bg:                  $white !default;
+// $modal-content-border-color:        rgba($black, .2) !default;
+// $modal-content-border-width:        $border-width !default;
+// $modal-content-border-radius:       $border-radius-lg !default;
+// $modal-content-box-shadow-xs:       0 .25rem .5rem rgba($black, .5) !default;
+// $modal-content-box-shadow-sm-up:    0 .5rem 1rem rgba($black, .5) !default;
+
+// $modal-backdrop-bg:                 $black !default;
+// $modal-backdrop-opacity:            .5 !default;
+// $modal-header-border-color:         $gray-200 !default;
+// $modal-footer-border-color:         $modal-header-border-color !default;
+// $modal-header-border-width:         $modal-content-border-width !default;
+// $modal-footer-border-width:         $modal-header-border-width !default;
+// $modal-header-padding:              1rem !default;
 
 // $modal-lg:                          800px !default;
 // $modal-md:                          500px !default;
@@ -757,7 +806,6 @@ $font-weight-bold:            700 !default;
 
 // $modal-transition:                  transform .3s ease-out !default;
 
-
 // // Alerts
 // //
 // // Define alert colors, border radius, and padding.
@@ -773,7 +821,6 @@ $font-weight-bold:            700 !default;
 // $alert-border-level:                -9 !default;
 // $alert-color-level:                 6 !default;
 
-
 // // Progress bars
 
 // $progress-height:                   1rem !default;
@@ -810,7 +857,6 @@ $font-weight-bold:            700 !default;
 // $list-group-action-active-color:    $body-color !default;
 // $list-group-action-active-bg:       $gray-200 !default;
 
-
 // // Image thumbnails
 
 // $thumbnail-padding:                 .25rem !default;
@@ -820,13 +866,11 @@ $font-weight-bold:            700 !default;
 // $thumbnail-border-radius:           $border-radius !default;
 // $thumbnail-box-shadow:              0 1px 2px rgba($black, .075) !default;
 
-
 // // Figures
 
 // $figure-caption-font-size:          90% !default;
 // $figure-caption-color:              $gray-600 !default;
 
-
 // // Breadcrumbs
 
 // $breadcrumb-padding-y:              .75rem !default;
@@ -838,8 +882,9 @@ $font-weight-bold:            700 !default;
 // $breadcrumb-bg:                     $gray-200 !default;
 // $breadcrumb-divider-color:          $gray-600 !default;
 // $breadcrumb-active-color:           $gray-600 !default;
-// $breadcrumb-divider:                "/" !default;
+// $breadcrumb-divider:                quote("/") !default;
 
+// $breadcrumb-border-radius:          $border-radius !default;
 
 // // Carousel
 
@@ -860,8 +905,7 @@ $font-weight-bold:            700 !default;
 // $carousel-control-prev-icon-bg:     str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='#{$carousel-control-color}' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E"), "#", "%23") !default;
 // $carousel-control-next-icon-bg:     str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='#{$carousel-control-color}' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E"), "#", "%23") !default;
 
-// $carousel-transition:               transform .6s ease !default;
-
+// $carousel-transition:               transform .6s ease !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)
 
 // // Close
 
@@ -884,7 +928,6 @@ $font-weight-bold:            700 !default;
 // $pre-color:                         $gray-900 !default;
 // $pre-scrollable-max-height:         340px !default;
 
-
 // // Printing
 // $print-page-size:                   a3 !default;
 // $print-body-min-width:              map-get($grid-breakpoints, "lg") !default;

+ 32 - 32
src/client/styles/bootstrap4/bootstrap-grid.scss

@@ -1,32 +1,32 @@
-/*!
- * Bootstrap Grid v4.0.0 (https://getbootstrap.com)
- * Copyright 2011-2018 The Bootstrap Authors
- * Copyright 2011-2018 Twitter, Inc.
- * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
- */
-
-@at-root {
-  @-ms-viewport { width: device-width; } // stylelint-disable-line at-rule-no-vendor-prefix
-}
-
-html {
-  box-sizing: border-box;
-  -ms-overflow-style: scrollbar;
-}
-
-*,
-*::before,
-*::after {
-  box-sizing: inherit;
-}
-
-@import "functions";
-@import "variables";
-
-@import "mixins/breakpoints";
-@import "mixins/grid-framework";
-@import "mixins/grid";
-
-@import "grid";
-@import "utilities/display";
-@import "utilities/flex";
+/*!
+ * Bootstrap Grid v4.1.3 (https://getbootstrap.com/)
+ * Copyright 2011-2018 The Bootstrap Authors
+ * Copyright 2011-2018 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ */
+
+@at-root {
+  @-ms-viewport { width: device-width; } // stylelint-disable-line at-rule-no-vendor-prefix
+}
+
+html {
+  box-sizing: border-box;
+  -ms-overflow-style: scrollbar;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: inherit;
+}
+
+@import "functions";
+@import "variables";
+
+@import "mixins/breakpoints";
+@import "mixins/grid-framework";
+@import "mixins/grid";
+
+@import "grid";
+@import "utilities/display";
+@import "utilities/flex";

Некоторые файлы не были показаны из-за большого количества измененных файлов