Просмотр исходного кода

Merge branch 'support/apply-nextjs-2' into fix/toggle-wide-view

Taichi Masuyama 3 лет назад
Родитель
Сommit
409b374c3d
32 измененных файлов с 571 добавлено и 437 удалено
  1. 0 0
      packages/app/_obsolete/src/client/services/ContextExtractor.tsx
  2. 1 1
      packages/app/config/rate-limiter.ts
  3. 10 4
      packages/app/src/components/ContentLinkButtons.tsx
  4. 72 12
      packages/app/src/components/InvitedForm.tsx
  5. 2 0
      packages/app/src/components/Layout/BasicLayout.tsx
  6. 1 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  7. 43 35
      packages/app/src/components/Page.tsx
  8. 6 4
      packages/app/src/components/Page/DisplaySwitcher.tsx
  9. 7 2
      packages/app/src/components/PageCreateModal.jsx
  10. 6 4
      packages/app/src/components/PageEditor.tsx
  11. 19 16
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  12. 0 172
      packages/app/src/components/PageEditor/DrawioModal.jsx
  13. 77 0
      packages/app/src/components/PageEditor/DrawioModal.tsx
  14. 8 2
      packages/app/src/components/PageEditor/Editor.tsx
  15. 51 46
      packages/app/src/components/PageEditorByHackmd.tsx
  16. 16 12
      packages/app/src/components/User/UserInfo.tsx
  17. 0 7
      packages/app/src/interfaces/services/renderer.ts
  18. 17 20
      packages/app/src/pages/[[...path]].page.tsx
  19. 45 0
      packages/app/src/server/middlewares/invited-form-validator.ts
  20. 0 43
      packages/app/src/server/middlewares/login-form-validator.ts
  21. 2 0
      packages/app/src/server/routes/apiv3/index.js
  22. 53 0
      packages/app/src/server/routes/apiv3/invited.ts
  23. 1 1
      packages/app/src/server/routes/index.js
  24. 6 1
      packages/app/src/server/routes/login-passport.js
  25. 0 45
      packages/app/src/server/routes/login.js
  26. 5 5
      packages/app/src/stores/context.tsx
  27. 97 0
      packages/app/src/stores/modal.tsx
  28. 4 2
      packages/app/src/stores/page.tsx
  29. 2 1
      packages/app/src/stores/ui.tsx
  30. 3 0
      packages/app/src/styles/molecules/toastr.scss
  31. 2 1
      packages/app/src/styles/style-next.scss
  32. 15 0
      packages/app/src/utils/drawio-config.ts

+ 0 - 0
packages/app/src/client/services/ContextExtractor.tsx → packages/app/_obsolete/src/client/services/ContextExtractor.tsx


+ 1 - 1
packages/app/config/rate-limiter.ts

@@ -33,7 +33,7 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
     maxRequests: MAX_REQUESTS_TIER_1,
     usersPerIpProspection: 100,
   },
-  '/invited/activateInvited': {
+  '/invited': {
     method: 'POST',
     maxRequests: MAX_REQUESTS_TIER_2,
   },

+ 10 - 4
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,8 +1,9 @@
 import React, { useCallback } from 'react';
 
+import { IUserHasId } from '@growi/core';
+
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-import { usePageUser } from '~/stores/context';
 
 import styles from './ContentLinkButtons.module.scss';
 
@@ -52,11 +53,16 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
-export const ContentLinkButtons = (): JSX.Element => {
 
-  const { data: pageUser } = usePageUser();
+export type ContentLinkButtonsProps = {
+  author?: IUserHasId,
+}
+
+export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
+
+  const { author } = props;
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
   }
 

+ 72 - 12
packages/app/src/components/InvitedForm.tsx

@@ -1,8 +1,12 @@
-import React from 'react';
+import React, { useCallback, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { useCurrentUser } from '../stores/context';
 
-import { useCsrfToken, useCurrentUser } from '../stores/context';
 
 export type InvitedFormProps = {
   invitedFormUsername: string,
@@ -10,23 +14,79 @@ export type InvitedFormProps = {
 }
 
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+
   const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
+  const router = useRouter();
   const { data: user } = useCurrentUser();
+  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
+  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
 
   const { invitedFormUsername, invitedFormName } = props;
 
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'invitedForm[name]': { value: name },
+      'invitedForm[password]': { value: password },
+      'invitedForm[username]': { value: username },
+    } = formData;
+
+    const invitedForm = {
+      name,
+      password,
+      username,
+    };
+
+    try {
+      const res = await apiv3Post('/invited', { invitedForm });
+      setIsConnectSuccess(true);
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      setLoginErrors(err);
+    }
+  }, [router]);
+
+  const formNotification = useCallback(() => {
+
+    if (isConnectSuccess) {
+      return (
+        <p className="alert alert-success">
+          <strong>{ t('message.successfully_connected') }</strong><br></br>
+        </p>
+      );
+    }
+
+    return (
+      <>
+        { loginErrors != null && loginErrors.length > 0 ? (
+          <p className="alert alert-danger">
+            { loginErrors.map((err, index) => {
+              return <span key={index}>{ t(err.message) }<br/></span>;
+            }) }
+          </p>
+        ) : (
+          <p className="alert alert-success">
+            <strong>{ t('invited.discription_heading') }</strong><br></br>
+            <small>{ t('invited.discription') }</small>
+          </p>
+        ) }
+      </>
+    );
+  }, [isConnectSuccess, loginErrors, t]);
+
   if (user == null) {
     return <></>;
   }
 
   return (
-    <div className="noLogin-dialog p-3 mx-auto" id="noLogin-dialog">
-      <p className="alert alert-success">
-        <strong>{ t('invited.discription_heading') }</strong><br></br>
-        <small>{ t('invited.discription') }</small>
-      </p>
-      <form role="form" action="/invited/activateInvited" method="post" id="invited-form">
+    <div className="noLogin-dialog px-3 pb-3 mx-auto" id="noLogin-dialog">
+      { formNotification() }
+      <form role="form" onSubmit={submitHandler} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
           <div className="input-group-prepend">
@@ -89,11 +149,11 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             placeholder={t('Password')}
             name="invitedForm[password]"
             required
+            minLength={6}
           />
         </div>
         {/* Create Button */}
-        <div className="input-group justify-content-center d-flex mt-5">
-          <input type="hidden" name="_csrf" value={csrfToken} />
+        <div className="input-group justify-content-center d-flex mt-4">
           <button type="submit" className="btn btn-fill" id="register">
             <div className="eff"></div>
             <span className="btn-label"><i className="icon-user-follow"></i></span>
@@ -101,7 +161,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
           </button>
         </div>
       </form>
-      <div className="input-group mt-5 d-flex justify-content-center">
+      <div className="input-group mt-4 d-flex justify-content-center">
         <a href="https://growi.org" className="link-growi-org">
           <span className="growi">GROWI</span>.<span className="org">ORG</span>
         </a>

+ 2 - 0
packages/app/src/components/Layout/BasicLayout.tsx

@@ -19,6 +19,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
@@ -58,6 +59,7 @@ export const BasicLayout = ({
       <PageRenameModal />
       <PagePresentationModal />
       <PageAccessoriesModal />
+      <DrawioModal />
       <HotkeysManager />
 
       <Fab />

+ 1 - 1
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -323,7 +323,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         <div className="d-flex">
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
             { isViewMode && (
-              <div className="h-50 w-100">
+              <div className="h-50">
                 { pageId != null && (
                   <SubNavButtons
                     isCompactMode={isCompactMode}

+ 43 - 35
packages/app/src/components/Page.tsx

@@ -3,12 +3,14 @@ import React, {
   useEffect, useRef, useState,
 } from 'react';
 
+import EventEmitter from 'events';
+
 import dynamic from 'next/dynamic';
 // import { debounce } from 'throttle-debounce';
 
 import { HtmlElementNode } from 'rehype-toc';
 
-// import { getOptionsToSave } from '~/client/util/editor';
+import { getOptionsToSave } from '~/client/util/editor';
 import {
   useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
 } from '~/stores/context';
@@ -23,11 +25,13 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
-
-// TODO: import dynamically
+import { DrawioModal } from './PageEditor/DrawioModal';
 // import MarkdownTable from '~/client/models/MarkdownTable';
-// import mdu from './PageEditor/MarkdownDrawioUtil';
-// import mtu from './PageEditor/MarkdownTableUtil';
+import mdu from './PageEditor/MarkdownDrawioUtil';
+import mtu from './PageEditor/MarkdownTableUtil';
+
+
+declare const globalEmitter: EventEmitter;
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
@@ -132,35 +136,35 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
   }
 
   async saveHandlerForDrawioModal(drawioData) {
-    // const {
-    //   isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
-    // } = this.props;
-    // const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    // const newMarkdown = mdu.replaceDrawioInMarkdown(
-    //   drawioData,
-    //   this.props.pageContainer.state.markdown,
-    //   this.state.currentTargetDrawioArea.beginLineNumber,
-    //   this.state.currentTargetDrawioArea.endLineNumber,
-    // );
-
-    // try {
-    //   // disable unsaved warning
-    //   mutateIsEnabledUnsavedWarning(false);
-
-    //   // eslint-disable-next-line no-unused-vars
-    //   const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //   logger.debug('success to save');
-
-    //   pageContainer.showSuccessToastr();
-    // }
-    // catch (error) {
-    //   logger.error('failed to save', error);
-    //   pageContainer.showErrorToastr(error);
-    // }
-    // finally {
-    //   this.setState({ currentTargetDrawioArea: null });
-    // }
+  //   const {
+  //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
+  //   } = this.props;
+  //   const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
+
+    //   const newMarkdown = mdu.replaceDrawioInMarkdown(
+    //     drawioData,
+    //     this.props.pageContainer.state.markdown,
+    //     this.state.currentTargetDrawioArea.beginLineNumber,
+    //     this.state.currentTargetDrawioArea.endLineNumber,
+    //   );
+
+    //   try {
+    //     // disable unsaved warning
+    //     mutateIsEnabledUnsavedWarning(false);
+
+    //     // eslint-disable-next-line no-unused-vars
+    //     const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
+    //     logger.debug('success to save');
+
+  //     pageContainer.showSuccessToastr();
+  //   }
+  //   catch (error) {
+  //     logger.error('failed to save', error);
+  //     pageContainer.showErrorToastr(error);
+  //   }
+  //   finally {
+  //     this.setState({ currentTargetDrawioArea: null });
+  //   }
   }
 
   override render() {
@@ -182,7 +186,11 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
             {/* <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} /> */}
-            {/* <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} /> */}
+            {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
+            {/* <DrawioModal
+              ref={this.drawioModal}
+              onSave={this.saveHandlerForDrawioModal}
+            /> */}
           </>
         )}
       </div>

+ 6 - 4
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -13,10 +13,12 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
+import { ContentLinkButtonsProps } from '../ContentLinkButtons';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import PageListIcon from '../Icons/PageListIcon';
 import { Page } from '../Page';
 import TableOfContents from '../TableOfContents';
+import { UserInfoProps } from '../User/UserInfo';
 
 import styles from './DisplaySwitcher.module.scss';
 
@@ -27,9 +29,9 @@ const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
 const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
 const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
-const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
+const ContentLinkButtons = dynamic<ContentLinkButtonsProps>(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
-const UserInfo = dynamic(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
+const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
 
 
 const PageView = React.memo((): JSX.Element => {
@@ -49,7 +51,7 @@ const PageView = React.memo((): JSX.Element => {
     <div className="d-flex flex-column flex-lg-row">
 
       <div className="flex-grow-1 flex-basis-0 mw-0">
-        { isUsersHomePagePath && <UserInfo /> }
+        { isUsersHomePagePath && <UserInfo author={currentPage?.creator} /> }
         { !isNotFound && <Page /> }
         { isNotFound && <NotFoundPage /> }
       </div>
@@ -94,7 +96,7 @@ const PageView = React.memo((): JSX.Element => {
 
             <div className="d-none d-lg-block">
               <TableOfContents />
-              { isUsersHomePagePath && <ContentLinkButtons /> }
+              { isUsersHomePagePath && <ContentLinkButtons author={currentPage?.creator} /> }
             </div>
 
           </div>

+ 7 - 2
packages/app/src/components/PageCreateModal.jsx

@@ -5,6 +5,7 @@ import React, {
 import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -21,6 +22,7 @@ const {
 
 const PageCreateModal = () => {
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: currentUser } = useCurrentUser();
 
@@ -98,7 +100,10 @@ const PageCreateModal = () => {
   async function redirectToEditor(...paths) {
     try {
       const editorPath = await generateEditorPath(...paths);
-      window.location.href = editorPath;
+      router.push(editorPath);
+
+      // close modal
+      closeCreateModal();
     }
     catch (err) {
       toastError(err);
@@ -203,7 +208,7 @@ const PageCreateModal = () => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    initializedPath={pageNameInput}
+                    initializedPath={pageNameInputInitialValue}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onInputChange={value => setPageNameInput(value)}

+ 6 - 4
packages/app/src/components/PageEditor.tsx

@@ -13,7 +13,8 @@ import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
-  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
+  useCurrentPagePath, useCurrentPathname, useCurrentPageId, useEditingMarkdown,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -51,6 +52,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageTags } = usePageTagsForEditors(pageId);
 
@@ -69,10 +71,10 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: rendererOptions } = usePreviewOptions();
 
   const currentRevisionId = currentPage?.revision?._id;
-  const initialValue = currentPage?.revision?.body;
+  const initialValue = editingMarkdown ?? '';
 
-  const markdownToSave = useRef<string>(initialValue ?? '');
-  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue ?? '');
+  const markdownToSave = useRef<string>(initialValue);
+  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 

+ 19 - 16
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 import { createValidator } from '@growi/codemirror-textlint';
 import { commands } from 'codemirror';
@@ -10,13 +10,14 @@ import * as loadScript from 'simple-load-script';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
+import { useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
 import CommentMentionHelper from './CommentMentionHelper';
-// import DrawioModal from './DrawioModal';
+import { DrawioModal } from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';
@@ -142,7 +143,6 @@ class CodeMirrorEditor extends AbstractEditor {
     this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
-    this.showDrawioHandler = this.showDrawioHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
@@ -799,10 +799,6 @@ class CodeMirrorEditor extends AbstractEditor {
     // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
-  showDrawioHandler() {
-    // this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
-  }
-
 
   // fold draw.io section (::: drawio ~ :::)
   foldDrawioSection() {
@@ -954,7 +950,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         bssize="small"
         title="draw.io"
-        onClick={this.showDrawioHandler}
+        onClick={() => this.props.onClickDrawioBtn(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()))}
       >
         <EditorIcon icon="Drawio" />
       </Button>,
@@ -1059,17 +1055,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
-        {/*
-        <HandsontableModal
+        {/* <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
         /> */}
-        {/* <DrawioModal
-          ref={this.drawioModal}
-          onSave={this.onSaveForDrawio}
-        /> */}
-
       </div>
     );
   }
@@ -1088,4 +1078,17 @@ CodeMirrorEditor.defaultProps = {
   lineNumbers: true,
 };
 
-export default CodeMirrorEditor;
+
+const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
+  const { open: openDrawioModal } = useDrawioModal();
+
+  const openDrawioModalHandler = useCallback((drawioMxFile) => {
+    openDrawioModal(drawioMxFile);
+  }, [openDrawioModal]);
+
+  return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} {...props} />;
+});
+
+CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';
+
+export default CodeMirrorEditorFc;

+ 0 - 172
packages/app/src/components/PageEditor/DrawioModal.jsx

@@ -1,172 +0,0 @@
-import React from 'react';
-
-import i18next from 'i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalBody,
-} from 'reactstrap';
-
-import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
-
-class DrawioModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      show: false,
-      drawioMxFile: '',
-    };
-
-    this.headerColor = '#334455';
-    this.fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
-
-    this.init = this.init.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.receiveFromDrawio = this.receiveFromDrawio.bind(this);
-  }
-
-  init(drawioMxFile) {
-    const initDrawioMxFile = drawioMxFile;
-    this.setState(
-      {
-        drawioMxFile: initDrawioMxFile,
-      },
-    );
-  }
-
-  show(drawioMxFile) {
-    this.init(drawioMxFile);
-
-    window.addEventListener('message', this.receiveFromDrawio);
-    this.setState({ show: true });
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-    });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  receiveFromDrawio(event) {
-    if (event.data === 'ready') {
-      event.source.postMessage(this.state.drawioMxFile, '*');
-      return;
-    }
-
-    if (event.data === '{"event":"configure"}') {
-      if (event.source == null) {
-        return;
-      }
-
-      // refs:
-      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
-      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
-      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
-      event.source.postMessage(JSON.stringify({
-        action: 'configure',
-        config: {
-          css: `
-          .geMenubarContainer { background-color: ${this.headerColor} !important; }
-          .geMenubar { background-color: ${this.headerColor} !important; }
-          .geEditor { font-family: ${this.fontFamily} !important; }
-          html td.mxPopupMenuItem {
-            font-family: ${this.fontFamily} !important;
-            font-size: 8pt !important;
-          }
-          `,
-          customFonts: ['Lato', 'Charter'],
-        },
-      }), '*');
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
-      if (event.data.length > 0) {
-        const parser = new DOMParser();
-        const dom = parser.parseFromString(event.data, 'text/xml');
-        const value = dom.getElementsByTagName('diagram')[0].innerHTML;
-
-        if (this.props.onSave != null) {
-          this.props.onSave(value);
-        }
-      }
-
-      window.removeEventListener('message', this.receiveFromDrawio);
-      this.hide();
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.length === 0) {
-      window.removeEventListener('message', this.receiveFromDrawio);
-      this.hide();
-
-      return;
-    }
-
-    // NOTHING DONE. (Receive unknown iframe message.)
-  }
-
-  get drawioURL() {
-    const { config } = this.props.appContainer;
-
-    const drawioUri = config.env.DRAWIO_URI || 'https://embed.diagrams.net/';
-    const url = new URL(drawioUri);
-
-    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
-    url.searchParams.append('spin', 1);
-    url.searchParams.append('embed', 1);
-    url.searchParams.append('lang', getDiagramsNetLangCode(i18next.language));
-    url.searchParams.append('ui', 'atlas');
-    url.searchParams.append('configure', 1);
-
-    return url;
-  }
-
-  render() {
-    return (
-      <Modal
-        isOpen={this.state.show}
-        toggle={this.cancel}
-        backdrop="static"
-        className="drawio-modal grw-body-only-modal-expanded"
-        size="xl"
-        keyboard={false}
-      >
-        <ModalBody className="p-0">
-          {/* Loading spinner */}
-          <div className="w-100 h-100 position-absolute d-flex">
-            <div className="mx-auto my-auto">
-              <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
-            </div>
-          </div>
-          {/* iframe */}
-          <div className="w-100 h-100 position-absolute d-flex">
-            { this.state.show && (
-              <iframe
-                src={this.drawioURL}
-                className="border-0 flex-grow-1"
-              >
-              </iframe>
-            ) }
-          </div>
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-}
-
-DrawioModal.propTypes = {
-  onSave: PropTypes.func,
-};
-
-
-export default DrawioModal;

+ 77 - 0
packages/app/src/components/PageEditor/DrawioModal.tsx

@@ -0,0 +1,77 @@
+import React, {
+  useMemo,
+} from 'react';
+
+import {
+  Modal,
+  ModalBody,
+} from 'reactstrap';
+
+
+import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
+import { useDrawioUri } from '~/stores/context';
+import { useDrawioModal } from '~/stores/modal';
+import { usePersonalSettings } from '~/stores/personal-settings';
+
+
+type Props = {
+  // onSave: (drawioData) => void,
+};
+
+export const DrawioModal = (props: Props): JSX.Element => {
+  const { data: growiDrawioUri } = useDrawioUri();
+  const { data: personalSettingsInfo } = usePersonalSettings();
+
+
+  const { data: drawioModalData, close: closeDrawioModal } = useDrawioModal();
+  const isOpened = drawioModalData?.isOpened ?? false;
+
+  const cancel = () => {
+    closeDrawioModal();
+  };
+
+  const drawioUrl = useMemo(() => {
+    const drawioUri = growiDrawioUri || 'https://embed.diagrams.net/';
+    const url = new URL(drawioUri);
+
+    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
+    url.searchParams.append('spin', '1');
+    url.searchParams.append('embed', '1');
+    url.searchParams.append('lang', getDiagramsNetLangCode(personalSettingsInfo?.lang || 'en'));
+    url.searchParams.append('ui', 'atlas');
+    url.searchParams.append('configure', '1');
+
+    return url;
+  }, [growiDrawioUri, personalSettingsInfo?.lang]);
+
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={cancel}
+      backdrop="static"
+      className="drawio-modal grw-body-only-modal-expanded"
+      size="xl"
+      keyboard={false}
+    >
+      <ModalBody className="p-0">
+        {/* Loading spinner */}
+        <div className="w-100 h-100 position-absolute d-flex">
+          <div className="mx-auto my-auto">
+            <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
+          </div>
+        </div>
+        {/* iframe */}
+        <div className="w-100 h-100 position-absolute d-flex">
+          { isOpened && (
+            <iframe
+              src={drawioUrl.href}
+              className="border-0 flex-grow-1"
+            >
+            </iframe>
+          ) }
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};

+ 8 - 2
packages/app/src/components/PageEditor/Editor.tsx

@@ -9,12 +9,14 @@ import {
 } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { IEditorSettings } from '~/interfaces/editor-settings';
 import { useDefaultIndentSize } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
 
 import { IEditorMethods } from '../../interfaces/editor-methods';
 
+import AbstractEditor from './AbstractEditor';
 import Cheatsheet from './Cheatsheet';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import pasteHelper from './PasteHelper';
@@ -31,7 +33,11 @@ export type EditorPropsType = {
   isTextlintEnabled?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
+  editorSettings?: IEditorSettings,
   indentSize?: number,
+  onDragEnter?: (event: any) => void,
+  onMarkdownHelpButtonClicked?: () => void,
+  onAddAttachmentButtonClicked?: () => void,
   onScroll?: ({ line: number }) => void,
   onScrollCursorIntoView?: (line: number) => void,
   onSave?: () => Promise<void>,
@@ -59,7 +65,8 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
   const { data: isMobile } = useIsMobile();
 
   const dropzoneRef = useRef<DropzoneRef>(null);
-  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  // CodeMirrorEditor ref
+  const cmEditorRef = useRef<AbstractEditor<any>>(null);
   const taEditorRef = useRef<TextAreaEditor>(null);
 
   const editorSubstance = useCallback(() => {
@@ -285,7 +292,6 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
 
                 {/* for PC */}
                 { !isMobile && (
-                  // eslint-disable-next-line arrow-body-style
                   <CodeMirrorEditor
                     ref={cmEditorRef}
                     indentSize={indentSize ?? defaultIndentSize}

+ 51 - 46
packages/app/src/components/PageEditorByHackmd.tsx

@@ -18,7 +18,7 @@ import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -44,12 +44,13 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
-  const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId);
+  const { data: pageTags } = usePageTagsForEditors(pageId);
+  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
 
   // pageData
-  const { data: pageData, mutate: updatePageData } = useSWRxCurrentPage();
+  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
 
   const slackChannels = slackChannelsData?.toString();
@@ -72,35 +73,40 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
-    if (editorMode !== EditorMode.HackMD) {
-      return;
-    }
+    if (editorMode !== EditorMode.HackMD) { return }
 
-    if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
-      return;
-    }
+    try {
+      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
+        throw new Error('Some materials to save are invalid');
+      }
 
-    let optionsToSave;
+      let optionsToSave;
 
-    const currentOptionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
+      const currentOptionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
 
-    if (opts != null) {
-      optionsToSave = Object.assign(currentOptionsToSave, {
-        ...opts,
-      });
-    }
-    else {
-      optionsToSave = currentOptionsToSave;
-    }
+      if (opts != null) {
+        optionsToSave = Object.assign(currentOptionsToSave, {
+          ...opts,
+        });
+      }
+      else {
+        optionsToSave = currentOptionsToSave;
+      }
 
-    const markdown = await hackmdEditorRef.current.getValue();
+      const markdown = await hackmdEditorRef.current.getValue();
 
-    await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
-    await updatePageData();
-    mutateEditorMode(EditorMode.View);
-    mutateIsEnabledUnsavedWarning(false);
+      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      await mutatePageData();
+      await mutateTagsInfo();
+      mutateEditorMode(EditorMode.View);
+      mutateIsEnabledUnsavedWarning(false);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error.message);
+    }
   }, [editorMode,
       isSlackEnabled,
       currentPathname,
@@ -110,8 +116,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       pageTags,
       pageId,
       currentPagePath,
-      updatePageData,
+      mutatePageData,
       mutateEditorMode,
+      mutateTagsInfo,
       mutateIsEnabledUnsavedWarning,
   ]);
 
@@ -150,7 +157,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
     }
     catch (err) {
-      toastError(err);
+      toastError(err.message);
 
       setHasError(true);
       setErrorMessage('GROWI server failed to connect to HackMD.');
@@ -189,7 +196,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
     }
     catch (err) {
       logger.error(err);
-      toastError(err);
+      toastError(err.message);
     }
   }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
 
@@ -198,36 +205,34 @@ export const PageEditorByHackmd = (): JSX.Element => {
    * @param {string} markdown
    */
   const onSaveWithShortcut = useCallback(async(markdown) => {
-    if (
-      isSlackEnabled == null || grant == null || slackChannels == null || pageId == null || revisionIdHackmdSynced == null || currentPathname == null
-    ) { return }
-    const optionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
-
     try {
-      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, markdown);
+      const currentPagePathOrPathname = currentPagePath || currentPathname;
+      if (
+        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
+        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+      ) { throw new Error('Some materials to save are invalid') }
+      const optionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
+      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, markdown);
 
       // update pageData
-      updatePageData();
+      mutatePageData(res);
 
       // set updated data
       setRemoteRevisionId(res.revision._id);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
-      updatePageTagsForEditors(res.tags);
+      mutateTagsInfo();
       mutateIsEnabledUnsavedWarning(false);
 
-      // call reset
-      setIsInitialized(false);
-
       logger.debug('success to save');
 
       toastSuccess(t('successfully_saved_the_page'));
     }
     catch (error) {
       logger.error('failed to save', error);
-      toastError(error);
+      toastError(error.message);
     }
   }, [isSlackEnabled,
       grant,
@@ -237,10 +242,10 @@ export const PageEditorByHackmd = (): JSX.Element => {
       currentPathname,
       pageTags,
       currentPagePath,
-      updatePageData,
+      mutatePageData,
       mutateRevisionIdHackmdSynced,
       mutateHasDraftOnHackmd,
-      updatePageTagsForEditors,
+      mutateTagsInfo,
       mutateIsEnabledUnsavedWarning,
       t]);
 
@@ -267,7 +272,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   }, [pageId, revision?.body, hackmdUri]);
 
   const penpalErrorOccuredHandler = useCallback((error) => {
-    toastError(error);
+    toastError(error.message);
 
     setHasError(true);
     setErrorMessage(t('hackmd.fail_to_connect'));

+ 16 - 12
packages/app/src/components/User/UserInfo.tsx

@@ -1,37 +1,41 @@
 import React from 'react';
 
+import { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 
-import { usePageUser } from '~/stores/context';
-
 import styles from './UserInfo.module.scss';
 
-export const UserInfo = (): JSX.Element => {
 
-  const { data: pageUser } = usePageUser();
+export type UserInfoProps = {
+  author?: IUserHasId,
+}
+
+export const UserInfo = (props: UserInfoProps): JSX.Element => {
+
+  const { author } = props;
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
   }
 
   return (
     <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`}>
-      <UserPicture user={pageUser} />
+      <UserPicture user={author} />
       <div className="users-meta">
         <h1 className="user-page-name">
-          {pageUser.name}
+          {author.name}
         </h1>
         <div className="user-page-meta mt-3 mb-0">
-          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{pageUser.username}</span>
+          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{author.username}</span>
           <span className="user-page-email mr-2">
             <i className="icon-envelope mr-1"></i>
-            { pageUser.isEmailPublished
-              ? pageUser.email
+            { author.isEmailPublished
+              ? author.email
               : '*****'
             }
           </span>
-          { pageUser.introduction && (
-            <span className="user-page-introduction">{pageUser.introduction}</span>
+          { author.introduction && (
+            <span className="user-page-introduction">{author.introduction}</span>
           ) }
         </div>
       </div>

+ 0 - 7
packages/app/src/interfaces/services/renderer.ts

@@ -1,12 +1,5 @@
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
-// export type GrowiHydratedEnv = {
-//   DRAWIO_URI: string | null,
-//   HACKMD_URI: string | null,
-//   NO_CDN: string | null,
-//   GROWI_CLOUD_URI: string | null,
-//   GROWI_APP_ID_FOR_GROWI_CLOUD: string | null,
-// }
 
 export type RendererConfig = {
   isEnabledLinebreaks: boolean,

+ 17 - 20
packages/app/src/pages/[[...path]].page.tsx

@@ -58,11 +58,11 @@ import {
   useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
-  useHackmdUri,
+  useDrawioUri, useHackmdUri,
   useIsAclEnabled, useIsUserPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, usePageUser,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage,
 } from '../stores/context';
 
 import {
@@ -126,7 +126,7 @@ const PutbackPageModal = (): JSX.Element => {
 type Props = CommonProps & {
   currentUser: IUser,
 
-  pageWithMeta: IPageToShowRevisionWithMeta,
+  pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
   redirectFrom?: string;
 
@@ -147,9 +147,9 @@ type Props = CommonProps & {
   // isMailerSetup: boolean,
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
-  // drawioUri: string,
+  drawioUri: string,
   hackmdUri: string,
-  // noCdn: string,
+  noCdn: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
   // isContainerFluid: boolean,
@@ -213,12 +213,11 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useIsMailerSetup(props.isMailerSetup);
   useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
-  // useDrawioUri(props.drawioUri);
+  useDrawioUri(props.drawioUri);
   useHackmdUri(props.hackmdUri);
   // useNoCdn(props.noCdn);
   // useIndentSize(props.adminPreferredIndentSize);
   useDisableLinkSharing(props.disableLinkSharing);
-
   useRendererConfig(props.rendererConfig);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
@@ -231,27 +230,25 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   const { pageWithMeta, userUISettings } = props;
 
-  let shouldRenderPutbackPageModal = false;
-  if (pageWithMeta != null) {
-    shouldRenderPutbackPageModal = _isTrashPage(pageWithMeta.data.path);
-  }
+  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
 
   const pageId = pageWithMeta?.data._id;
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
-  useCurrentPageId(pageId);
-  useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
+  useCurrentPageId(pageId ?? null);
   useIsUserPage(pagePath != null && isUserPage(pagePath));
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
-  useEditingMarkdown(pageWithMeta?.data.revision?.body);
   useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
-  usePageUser(pageWithMeta?.data.creator);
+  const shouldRenderPutbackPageModal = pageWithMeta != null
+    ? _isTrashPage(pageWithMeta.data.path)
+    : false;
 
   // sync grant data
   useEffect(() => {
@@ -329,8 +326,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
           { !props.isIdenticalPathPage && !props.isNotFound && (
             <footer className="footer d-edit-none">
-              { !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta?.data.revision} />) }
-              { (pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path)) && (
+              { pageWithMeta != null && !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta.data.revision} />) }
+              { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
               ) }
               <CurrentPageContentFooter />
@@ -396,7 +393,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     }
   }
 
-  const pageWithMeta: IPageToShowRevisionWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
+  const pageWithMeta: IPageToShowRevisionWithMeta | null = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const page = pageWithMeta?.data as unknown as PageDocument;
 
   // add user to seen users
@@ -493,9 +490,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.isMailerSetup = mailService.isMailerSetup;
   props.isAclEnabled = aclService.isAclEnabled();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
-  // props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
   props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');
-  // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
+  props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');

+ 45 - 0
packages/app/src/server/middlewares/invited-form-validator.ts

@@ -0,0 +1,45 @@
+import { NextFunction, Response } from 'express';
+import { body, validationResult, ValidationChain } from 'express-validator';
+import { Request } from 'express-validator/src/base';
+
+const MININUM_PASSWORD_LENGTH = 6;
+
+export const invitedRules = (): ValidationChain[] => {
+  return [
+    body('invitedForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('message.Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('message.Username field is required'),
+    body('invitedForm.name')
+      .not()
+      .isEmpty()
+      .withMessage('message.Name field is required'),
+    body('invitedForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('message.Password has invalid character')
+      .isLength({ min: MININUM_PASSWORD_LENGTH })
+      .withMessage(`message.Password minimum character should be more than ${MININUM_PASSWORD_LENGTH} characters`)
+      .not()
+      .isEmpty()
+      .withMessage('message.Password field is required'),
+  ];
+};
+
+export const invitedValidation = (req: Request, _res: Response, next: () => NextFunction): any => {
+  const form = req.body;
+  const errors = validationResult(req);
+  const extractedErrors: string[] = [];
+
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+  }
+  else {
+    errors.array().map(err => extractedErrors.push(err.msg));
+    Object.assign(form, { isValid: false, errors: extractedErrors });
+  }
+
+  req.form = form;
+  return next();
+};

+ 0 - 43
packages/app/src/server/middlewares/login-form-validator.ts

@@ -1,48 +1,5 @@
 import { body, validationResult } from 'express-validator';
 
-// form rules
-export const inviteRules = () => {
-  return [
-    body('invitedForm.username')
-      .matches(/^[\da-zA-Z\-_.]+$/)
-      .withMessage('Username has invalid characters')
-      .not()
-      .isEmpty()
-      .withMessage('Username field is required'),
-    body('invitedForm.name').not().isEmpty().withMessage('Name field is required'),
-    body('invitedForm.password')
-      .matches(/^[\x20-\x7F]*$/)
-      .withMessage('Password has invalid character')
-      .isLength({ min: 6 })
-      .withMessage('Password minimum character should be more than 6 characters')
-      .not()
-      .isEmpty()
-      .withMessage('Password field is required'),
-  ];
-};
-
-// validation action
-export const inviteValidation = (req, res, next) => {
-  const form = req.body;
-
-  const errors = validationResult(req);
-  if (errors.isEmpty()) {
-    Object.assign(form, { isValid: true });
-    req.form = form;
-    return next();
-  }
-
-  const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
-
-  req.flash('errorMessages', extractedErrors);
-
-  Object.assign(form, { isValid: false });
-  req.form = form;
-
-  return next();
-};
-
 // form rules
 export const loginRules = () => {
   return [

+ 2 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -49,11 +49,13 @@ module.exports = (crowi, app, isInstalled) => {
   routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
     addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
 
+  routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
 
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
+
   // installer
   if (!isInstalled) {
     routerForAdmin.use('/installer', require('./installer')(crowi));

+ 53 - 0
packages/app/src/server/routes/apiv3/invited.ts

@@ -0,0 +1,53 @@
+import express, { Request, Router } from 'express';
+
+import Crowi from '../../crowi';
+import { invitedRules, invitedValidation } from '../../middlewares/invited-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+type InvitedFormRequest = Request & { form: any, user: any };
+
+module.exports = (crowi: Crowi): Router => {
+  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const debug = require('debug')('growi:routes:login');
+  const User = crowi.model('User');
+  const router = express.Router();
+
+  router.post('/', applicationInstalled, invitedRules(), invitedValidation, async(req: InvitedFormRequest, res: ApiV3Response) => {
+    if (!req.user) {
+      return res.apiv3({ redirectTo: '/login' });
+    }
+
+    if (!req.form.isValid) {
+      return res.apiv3Err(req.form.errors, 400);
+    }
+
+    const user = req.user;
+    const invitedForm = req.form.invitedForm || {};
+    const username = invitedForm.username;
+    const name = invitedForm.name;
+    const password = invitedForm.password;
+
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      return res.apiv3Err('message.can_not_activate_maximum_number_of_users', 403);
+    }
+
+    const creatable = await User.isRegisterableUsername(username);
+    if (!creatable) {
+      debug('username', username);
+      return res.apiv3Err('message.unable_to_use_this_user', 403);
+    }
+
+    try {
+      await user.activateInvitedUser(username, name, password);
+      return res.apiv3({ redirectTo: '/' });
+    }
+    catch (err) {
+      return res.apiv3Err('message.failed_to_activate', 403);
+    }
+  });
+
+  return router;
+};

+ 1 - 1
packages/app/src/server/routes/index.js

@@ -81,7 +81,7 @@ module.exports = function(crowi, app) {
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
-  app.post('/invited/activateInvited' , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
+  // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 

+ 6 - 1
packages/app/src/server/routes/login-passport.js

@@ -11,6 +11,7 @@ module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
   const ExternalAccount = crowi.model('ExternalAccount');
+  const User = crowi.model('User');
   const passportService = crowi.passportService;
 
   const activityEvent = crowi.event('activity');
@@ -91,6 +92,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   const loginSuccessHandler = async(req, res, user, action) => {
+
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
@@ -99,7 +101,9 @@ module.exports = function(crowi, app) {
       }
     });
 
-    const { redirectTo } = req.session;
+    // check for redirection to '/invited'
+    const redirectTo = req.user.status === User.STATUS_INVITED ? '/invited' : req.session.redirectTo;
+
     // remove session.redirectTo
     delete req.session.redirectTo;
 
@@ -112,6 +116,7 @@ module.exports = function(crowi, app) {
         username: req.user.username,
       },
     };
+
     await crowi.activityService.createActivity(parameters);
 
     return res.apiv3({ redirectTo });

+ 0 - 45
packages/app/src/server/routes/login.js

@@ -169,50 +169,5 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.invited = async function(req, res) {
-    if (!req.user) {
-      return res.redirect('/login');
-    }
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const user = req.user;
-      const invitedForm = req.form.invitedForm || {};
-      const username = invitedForm.username;
-      const name = invitedForm.name;
-      const password = invitedForm.password;
-
-      // check user upper limit
-      const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-      if (isUserCountExceedsUpperLimit) {
-        req.flash('warningMessage', req.t('message.can_not_activate_maximum_number_of_users'));
-        return res.redirect('/invited');
-      }
-
-      const creatable = await User.isRegisterableUsername(username);
-      if (creatable) {
-        try {
-          await user.activateInvitedUser(username, name, password);
-          return res.redirect('/');
-        }
-        catch (err) {
-          req.flash('warningMessage', req.t('message.failed_to_activate'));
-          return res.render('invited');
-        }
-      }
-      else {
-        req.flash('warningMessage', req.t('message.unable_to_use_this_user'));
-        debug('username', username);
-        return res.render('invited');
-      }
-    }
-    else {
-      return res.render('invited');
-    }
-  };
-
-  actions.updateInvitedUser = function(req, res) {
-    return res.redirect('/');
-  };
-
   return actions;
 };

+ 5 - 5
packages/app/src/stores/context.tsx

@@ -1,3 +1,4 @@
+import { IUser } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -11,7 +12,6 @@ import { GrowiThemes } from '~/interfaces/theme';
 import InterceptorManager from '~/services/interceptor-manager';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
-import { IUser, IUserHasId } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
@@ -98,10 +98,6 @@ export const useIsNotFound = (initialData?: boolean): SWRResponse<boolean, Error
   return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
 };
 
-export const usePageUser = (initialData?: IUserHasId): SWRResponse<IUserHasId, Error> => {
-  return useStaticSWR<IUserHasId, Error>('pageUser', initialData);
-};
-
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
 };
@@ -134,6 +130,10 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
 };
 
+export const useDrawioUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('drawioUri', initialData);
+};
+
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
   return useStaticSWR<Nullable<string>, Error>('hackmdUri', initialData);
 };

+ 97 - 0
packages/app/src/stores/modal.tsx

@@ -5,6 +5,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import { IUserGroupHasId } from '~/interfaces/user';
+import { dwawioConfig } from '~/utils/drawio-config';
 
 import { useStaticSWR } from './use-static-swr';
 
@@ -438,3 +439,99 @@ export const useShortcutsModal = (): SWRResponse<ShortcutsModalStatus, Error> &
     },
   };
 };
+
+
+/*
+* DrawioModal
+*/
+
+type DrawioModalStatus = {
+  isOpened: boolean,
+  drawioMxFile: string,
+}
+
+type DrawioModalStatusUtils = {
+  open(drawioMxFile: string): void,
+  close(): void,
+  receiveFromDrawio(event, drawioMxFile: string): void,
+}
+
+export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioModalStatus, Error> & DrawioModalStatusUtils => {
+  const initialData: DrawioModalStatus = {
+    isOpened: false,
+    drawioMxFile: '',
+  };
+  const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
+
+  const close = (): void => {
+    swrResponse.mutate({ isOpened: false, drawioMxFile: '' });
+  };
+
+  const receiveFromDrawio = (event, drawioMxFile: string) => {
+
+    if (event.data === 'ready') {
+      event.source.postMessage(drawioMxFile, '*');
+      return;
+    }
+
+    if (event.data === '{"event":"configure"}') {
+      if (event.source == null) {
+        return;
+      }
+
+      // refs:
+      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
+      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
+      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
+      event.source.postMessage(JSON.stringify({
+        action: 'configure',
+        config: dwawioConfig,
+      }), '*');
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
+      if (event.data.length > 0) {
+        const parser = new DOMParser();
+        const dom = parser.parseFromString(event.data, 'text/xml');
+        const drawioData = dom.getElementsByTagName('diagram')[0].innerHTML;
+
+        /*
+        * Saving Drawio will be implemented by the following tasks
+        * https://redmine.weseek.co.jp/issues/100845
+        * https://redmine.weseek.co.jp/issues/104507
+        */
+
+        // if (props.onSave != null) {
+        //   props.onSave(drawioData);
+        // }
+      }
+
+      window.removeEventListener('message', () => receiveFromDrawio);
+      close();
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.length === 0) {
+      close();
+
+      return;
+    }
+
+    // NOTHING DONE. (Receive unknown iframe message.)
+  };
+
+  const open = (drawioMxFile: string): void => {
+    window.addEventListener('message', e => receiveFromDrawio(e, drawioMxFile));
+    swrResponse.mutate({ isOpened: true, drawioMxFile });
+  };
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+    receiveFromDrawio,
+  };
+};

+ 4 - 2
packages/app/src/stores/page.tsx

@@ -39,14 +39,16 @@ export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToSh
   );
 };
 
-export const useSWRxCurrentPage = (shareLinkId?: string, initialData?: IPagePopulatedToShowRevision): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
+export const useSWRxCurrentPage = (
+    shareLinkId?: string, initialData?: IPagePopulatedToShowRevision|null,
+): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
   const { data: currentPageId } = useCurrentPageId();
 
   const swrResult = useSWRxPage(currentPageId, shareLinkId);
 
   // use mutate because fallbackData does not work
   // see: https://github.com/weseek/growi/commit/5038473e8d6028c9c91310e374a7b5f48b921a15
-  if (initialData != null) {
+  if (initialData !== undefined) {
     swrResult.mutate(initialData);
   }
 

+ 2 - 1
packages/app/src/stores/ui.tsx

@@ -414,10 +414,11 @@ export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> =>
   const pageId = currentPageId;
   const includesUndefined = [pageId, isTrashPage, isSharedUser, isNotFound].some(v => v === undefined);
   const isPageExist = (pageId != null) && !isNotFound;
+  const isEmptyPage = (pageId != null) && isNotFound;
 
   return useSWRImmutable(
     includesUndefined ? null : [key, pageId],
-    () => isPageExist && !isTrashPage && !isSharedUser,
+    () => (isPageExist && !isTrashPage && !isSharedUser) || (isEmptyPage != null && isEmptyPage),
   );
 };
 

+ 3 - 0
packages/app/src/styles/molecules/toastr.scss

@@ -0,0 +1,3 @@
+:root {
+  @import '~toastr/build/toastr';
+}

+ 2 - 1
packages/app/src/styles/style-next.scss

@@ -30,7 +30,8 @@
 @import 'atoms/spinners';
 @import 'atoms/custom_control';
 
-// // molecules
+// molecules
+@import 'molecules/toastr';
 // @import 'molecules/copy-dropdown';
 // @import 'molecules/page-editor-mode-manager';
 // @import 'molecules/slack-notification';

+ 15 - 0
packages/app/src/utils/drawio-config.ts

@@ -0,0 +1,15 @@
+const headerColor = '#334455';
+const fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+
+export const dwawioConfig = {
+  css: `
+  .geMenubarContainer { background-color: ${headerColor} !important; }
+  .geMenubar { background-color: ${headerColor} !important; }
+  .geEditor { font-family: ${fontFamily} !important; }
+  html td.mxPopupMenuItem {
+    font-family: ${fontFamily} !important;
+    font-size: 8pt !important;
+  }
+  `,
+  customFonts: ['Lato', 'Charter'],
+};