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

Merge branch 'support/apply-nextjs-2' into support/ci-for-prod

Yuki Takei 3 лет назад
Родитель
Сommit
d0894fe041
31 измененных файлов с 368 добавлено и 361 удалено
  1. 0 3
      packages/app-next/.eslintrc.json
  2. 0 35
      packages/app-next/.gitignore
  3. 0 20
      packages/app-next/tsconfig.json
  4. 1 4
      packages/app/src/client/services/AdminCustomizeContainer.js
  5. 1 0
      packages/app/src/client/services/PageContainer.js
  6. 18 20
      packages/app/src/components/Admin/Customize/Customize.jsx
  7. 4 2
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  8. 2 13
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  9. 1 0
      packages/app/src/components/Admin/Customize/ThemeColorBox.jsx
  10. 2 1
      packages/app/src/components/Admin/SlackIntegration/Bridge.jsx
  11. 14 18
      packages/app/src/components/Layout/Admin.module.scss
  12. 4 1
      packages/app/src/components/Layout/AdminLayout.tsx
  13. 0 199
      packages/app/src/components/PageAttachment.jsx
  14. 151 0
      packages/app/src/components/PageAttachment.tsx
  15. 9 8
      packages/app/src/components/Theme/ThemeIsland.module.scss
  16. 8 0
      packages/app/src/components/Theme/ThemeIsland.tsx
  17. 11 10
      packages/app/src/components/Theme/ThemeMonoBlue.module.scss
  18. 8 0
      packages/app/src/components/Theme/ThemeMonoBlue.tsx
  19. 7 7
      packages/app/src/components/Theme/ThemeNature.module.scss
  20. 8 0
      packages/app/src/components/Theme/ThemeNature.tsx
  21. 9 8
      packages/app/src/components/Theme/ThemeSpring.module.scss
  22. 8 0
      packages/app/src/components/Theme/ThemeSpring.tsx
  23. 7 7
      packages/app/src/components/Theme/ThemeWood.module.scss
  24. 8 0
      packages/app/src/components/Theme/ThemeWood.tsx
  25. 15 0
      packages/app/src/components/Theme/utils/ThemeProvider.tsx
  26. 10 1
      packages/app/src/interfaces/attachment.ts
  27. 2 1
      packages/app/src/pages/[[...path]].page.tsx
  28. 3 3
      packages/app/src/server/routes/apiv3/attachment.js
  29. 52 0
      packages/app/src/stores/attachment.tsx
  30. 4 0
      packages/app/src/stores/context.tsx
  31. 1 0
      packages/core/src/index.ts

+ 0 - 3
packages/app-next/.eslintrc.json

@@ -1,3 +0,0 @@
-{
-  "extends": "next/core-web-vitals"
-}

+ 0 - 35
packages/app-next/.gitignore

@@ -1,35 +0,0 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-.pnpm-debug.log*
-
-# local env files
-.env*.local
-
-# vercel
-.vercel
-
-# typescript
-*.tsbuildinfo

+ 0 - 20
packages/app-next/tsconfig.json

@@ -1,20 +0,0 @@
-{
-  "compilerOptions": {
-    "target": "es5",
-    "lib": ["dom", "dom.iterable", "esnext"],
-    "allowJs": true,
-    "skipLibCheck": true,
-    "strict": true,
-    "forceConsistentCasingInFileNames": true,
-    "noEmit": true,
-    "esModuleInterop": true,
-    "module": "esnext",
-    "moduleResolution": "node",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "jsx": "preserve",
-    "incremental": true
-  },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
-  "exclude": ["node_modules"]
-}

+ 1 - 4
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -17,13 +17,10 @@ export default class AdminCustomizeContainer extends Container {
   constructor() {
     super();
 
-    this.dummyCurrentTheme = 0;
-    this.dummyCurrentThemeForError = 1;
-
     this.state = {
       retrieveError: null,
       // set dummy value tile for using suspense
-      currentTheme: this.dummyCurrentTheme,
+      currentTheme: 'default',
       isEnabledTimeline: false,
       isSavedStatesOfTabChanges: false,
       isEnabledAttachTitleHeader: false,

+ 1 - 0
packages/app/src/client/services/PageContainer.js

@@ -135,6 +135,7 @@ export default class PageContainer extends Container {
 
   /**
    * initialize state for markdown data
+   * [Already SWRized]
    */
   initStateMarkdown() {
     let pageContent = '';

+ 18 - 20
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -1,5 +1,5 @@
 
-import React from 'react';
+import React, { useEffect } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -23,28 +23,24 @@ import CustomizeTitle from './CustomizeTitle';
 
 const logger = loggerFactory('growi:services:AdminCustomizePage');
 
-const retrieveErrors = null;
 function Customize(props) {
   const { adminCustomizeContainer } = props;
 
-  // if (adminCustomizeContainer.state.currentTheme === adminCustomizeContainer.dummyCurrentTheme) {
-  //   throw (async() => {
-  //     try {
-  //       await adminCustomizeContainer.retrieveCustomizeData();
-  //     }
-  //     catch (err) {
-  //       const errs = toArrayIfNot(err);
-  //       toastError(errs);
-  //       logger.error(errs);
-  //       retrieveErrors = errs;
-  //       adminCustomizeContainer.setState({ currentTheme: adminCustomizeContainer.dummyCurrentThemeForError });
-  //     }
-  //   })();
-  // }
+  useEffect(() => {
+    async function fetchCustomizeSettingsData() {
+      await adminCustomizeContainer.retrieveCustomizeData();
+    }
+
+    try {
+      fetchCustomizeSettingsData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+      logger.error(errs);
+    }
+  }, [adminCustomizeContainer]);
 
-  // if (adminCustomizeContainer.state.currentTheme === adminCustomizeContainer.dummyCurrentThemeForError) {
-  //   throw new Error(`${retrieveErrors.length} errors occured`);
-  // }
 
   return (
     <div data-testid="admin-customize">
@@ -55,7 +51,9 @@ function Customize(props) {
         <CustomizeThemeSetting />
       </div>
       <div className="mb-5">
-        <CustomizeSidebarSetting />
+        {/* TODO: [resolve browser err] A component is changing an uncontrolled input to be controlled. by https://redmine.weseek.co.jp/issues/101155
+          <CustomizeSidebarSetting />
+        */}
       </div>
       <div className="mb-5">
         <CustomizeFunctionSetting />

+ 4 - 2
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { GrowiThemes } from '~/interfaces/theme';
+import { useGrowiTheme } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -51,6 +52,7 @@ const CustomizeThemeOptions = (props) => {
 
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
+  const { mutate: mutateGrowiTheme } = useGrowiTheme();
   const { currentLayout, currentTheme } = adminCustomizeContainer.state;
 
   return (
@@ -64,7 +66,7 @@ const CustomizeThemeOptions = (props) => {
               <ThemeColorBox
                 key={theme.name}
                 isSelected={currentTheme === theme.name}
-                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                onSelected={() => mutateGrowiTheme(theme.name)}
                 {...theme}
               />
             );
@@ -80,7 +82,7 @@ const CustomizeThemeOptions = (props) => {
               <ThemeColorBox
                 key={theme.name}
                 isSelected={currentTheme === theme.name}
-                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                onSelected={() => mutateGrowiTheme(theme.name)}
                 {...theme}
               />
             );

+ 2 - 13
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -19,7 +19,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const submitHandler = useCallback(async() => {
     try {
       await adminCustomizeContainer.updateCustomizeTheme();
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
@@ -29,24 +29,13 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     }
   }, [t, adminCustomizeContainer]);
 
-  const renderDevAlert = useCallback(() => {
-    if (process.env.NODE_ENV === 'development') {
-      return (
-        <div className="alert alert-warning">
-          <strong>DEBUG MESSAGE:</strong> Live preview for theme is disabled in development mode.
-        </div>
-      );
-    }
-  }, []);
-
   return (
     <React.Fragment>
       <div className="row">
         <div className="col-12">
           <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
-          {renderDevAlert()}
           <CustomizeThemeOptions />
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+          <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
         </div>
       </div>
     </React.Fragment>

+ 1 - 0
packages/app/src/components/Admin/Customize/ThemeColorBox.jsx

@@ -1,4 +1,5 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 
 

+ 2 - 1
packages/app/src/components/Admin/SlackIntegration/Bridge.jsx

@@ -1,4 +1,5 @@
 import React from 'react';
+
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -20,7 +21,7 @@ const BridgeCore = (props) => {
   return (
     <>
       <div id="grw-bridge-container" className={`grw-bridge-container ${withProxy ? 'with-proxy' : ''}`}>
-        <p className="label">
+        <p className={`${withProxy ? 'mt-0' : 'mt-2'}`}>
           <i className={iconClass} />
           <small
             className="ml-2 d-none d-lg-inline"

+ 14 - 18
packages/app/src/styles/_admin.scss → packages/app/src/components/Layout/Admin.module.scss

@@ -1,14 +1,17 @@
+@use '~/styles/bootstrap/init' as *;
+@use '~/styles/mixins';
+
 $slack-work-space-name-card-background: #fff5ff;
 $slack-work-space-name-card-border: #efc1f6;
 
-.admin-page {
+.admin-page :global {
   .title {
     padding-top: 1rem;
     padding-bottom: 1rem;
 
     line-height: 1em;
 
-    @include variable-font-size(28px);
+    @include mixins.variable-font-size(28px);
     line-height: 1.1em;
   }
 
@@ -28,8 +31,6 @@ $slack-work-space-name-card-border: #efc1f6;
   }
 
   .admin-customize {
-    @import 'hljs';
-
     .ss-container img {
       padding: 0.5em;
       background-color: $gray-300;
@@ -169,15 +170,8 @@ $slack-work-space-name-card-border: #efc1f6;
 
     // switch layout for Bridge component
     .grw-bridge-container {
-      .label {
-        @extend .mt-5;
-      }
-
       // with ProxyCircle
       &.with-proxy {
-        .label {
-          @extend .mt-0;
-        }
         .hr-container {
           margin-top: 40px;
           @include media-breakpoint-up(lg) {
@@ -293,13 +287,15 @@ $slack-work-space-name-card-border: #efc1f6;
       background-color: rgba($info, 0.1);
     }
   }
-}
 
-.admin-navigation {
-  & > a + a {
-    margin-top: 2px;
-  }
-  &.sticky-top {
-    top: 30px;
+  .admin-navigation {
+    & > a + a {
+      margin-top: 2px;
+    }
+    &.sticky-top {
+      top: 30px;
+    }
   }
 }
+
+

+ 4 - 1
packages/app/src/components/Layout/AdminLayout.tsx

@@ -7,6 +7,9 @@ import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 import { RawLayout } from './RawLayout';
 
+import styles from './Admin.module.scss';
+
+
 // import { injectableContainers } from '~/client/admin';
 
 type Props = {
@@ -29,7 +32,7 @@ const AdminLayout = ({
   const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 
   return (
-    <RawLayout title={title}>
+    <RawLayout title={title} className={`admin-page ${styles['admin-page']}`}>
       <GrowiNavbar />
 
       <header className="py-0">

+ 0 - 199
packages/app/src/components/PageAttachment.jsx

@@ -1,199 +0,0 @@
-/* eslint-disable react/no-access-state-in-setstate */
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import PageContainer from '~/client/services/PageContainer';
-import { apiPost } from '~/client/util/apiv1-client';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { useIsGuestUser } from '~/stores/context';
-
-import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
-import PageAttachmentList from './PageAttachment/PageAttachmentList';
-import PaginationWrapper from './PaginationWrapper';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-class PageAttachment extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      activePage: 1,
-      totalAttachments: 0,
-      limit: Infinity,
-      attachments: [],
-      inUse: {},
-      attachmentToDelete: null,
-      deleting: false,
-      deleteError: '',
-    };
-
-    this.handlePage = this.handlePage.bind(this);
-    this.onAttachmentDeleteClicked = this.onAttachmentDeleteClicked.bind(this);
-    this.onAttachmentDeleteClickedConfirm = this.onAttachmentDeleteClickedConfirm.bind(this);
-  }
-
-
-  async handlePage(selectedPage) {
-    const { pageId } = this.props.pageContainer.state;
-    const page = selectedPage;
-
-    if (!pageId) { return }
-
-    const res = await apiv3Get('/attachment/list', { pageId, page });
-    const attachments = res.data.paginateResult.docs;
-    const totalAttachments = res.data.paginateResult.totalDocs;
-    const pagingLimit = res.data.paginateResult.limit;
-
-    const inUse = {};
-
-    for (const attachment of attachments) {
-      inUse[attachment._id] = this.checkIfFileInUse(attachment);
-    }
-    this.setState({
-      activePage: selectedPage,
-      totalAttachments,
-      limit: pagingLimit,
-      attachments,
-      inUse,
-    });
-  }
-
-
-  async componentDidMount() {
-    await this.handlePage(1);
-    this.setState({
-      activePage: 1,
-    });
-  }
-
-  checkIfFileInUse(attachment) {
-    const { markdown } = this.props.pageContainer.state;
-
-    if (markdown.match(attachment._id)) {
-      return true;
-    }
-    return false;
-  }
-
-  onAttachmentDeleteClicked(attachment) {
-    this.setState({
-      attachmentToDelete: attachment,
-    });
-  }
-
-  onAttachmentDeleteClickedConfirm(attachment) {
-    const attachmentId = attachment._id;
-    this.setState({
-      deleting: true,
-    });
-
-    apiPost('/attachments.remove', { attachment_id: attachmentId })
-      .then((res) => {
-        this.setState({
-          attachments: this.state.attachments.filter((at) => {
-            // comparing ObjectId
-            // eslint-disable-next-line eqeqeq
-            return at._id != attachmentId;
-          }),
-          attachmentToDelete: null,
-          deleting: false,
-        });
-      }).catch((err) => {
-        this.setState({
-          deleteError: 'Something went wrong.',
-          deleting: false,
-        });
-      });
-  }
-
-
-  render() {
-    const { t, isGuestUser } = this.props;
-
-    if (this.state.attachments.length === 0) {
-      return (
-        <div data-testid="page-attachment">
-          {t('No_attachments_yet')}
-        </div>
-      );
-    }
-
-    let deleteAttachmentModal = '';
-    if (!isGuestUser) {
-      const attachmentToDelete = this.state.attachmentToDelete;
-      const deleteModalClose = () => {
-        this.setState({ attachmentToDelete: null, deleteError: '' });
-      };
-      const showModal = attachmentToDelete !== null;
-
-      let deleteInUse = null;
-      if (attachmentToDelete !== null) {
-        deleteInUse = this.state.inUse[attachmentToDelete._id] || false;
-      }
-
-      deleteAttachmentModal = (
-        <DeleteAttachmentModal
-          isOpen={showModal}
-          animation="false"
-          toggle={deleteModalClose}
-          attachmentToDelete={attachmentToDelete}
-          inUse={deleteInUse}
-          deleting={this.state.deleting}
-          deleteError={this.state.deleteError}
-          onAttachmentDeleteClickedConfirm={this.onAttachmentDeleteClickedConfirm}
-        />
-      );
-    }
-
-    return (
-      <div data-testid="page-attachment">
-        <PageAttachmentList
-          attachments={this.state.attachments}
-          inUse={this.state.inUse}
-          onAttachmentDeleteClicked={this.onAttachmentDeleteClicked}
-          isUserLoggedIn={!isGuestUser}
-        />
-
-        {deleteAttachmentModal}
-
-        <PaginationWrapper
-          activePage={this.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={this.state.totalAttachments}
-          pagingLimit={this.state.limit}
-          align="center"
-        />
-      </div>
-    );
-  }
-
-}
-
-PageAttachment.propTypes = {
-  t: PropTypes.func.isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isGuestUser: PropTypes.bool.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAttachmentUnstatedWrapper = withUnstatedContainers(PageAttachment, [PageContainer]);
-
-const PageAttachmentWrapper = (props) => {
-  const { t } = useTranslation();
-  const { data: isGuestUser } = useIsGuestUser();
-
-  if (isGuestUser == null) {
-    return <></>;
-  }
-
-  return <PageAttachmentUnstatedWrapper {...props} t={t} isGuestUser={isGuestUser} />;
-};
-
-export default PageAttachmentWrapper;

+ 151 - 0
packages/app/src/components/PageAttachment.tsx

@@ -0,0 +1,151 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useSWRxAttachments } from '~/stores/attachment';
+import { useEditingMarkdown, useCurrentPageId, useIsGuestUser } from '~/stores/context';
+
+import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import PaginationWrapper from './PaginationWrapper';
+
+// Utility
+const checkIfFileInUse = (markdown: string, attachment) => {
+  return markdown.match(attachment._id);
+};
+
+// Custom hook that handles processes related to inUseAttachments
+const useInUseAttachments = (attachments) => {
+  const { data: markdown } = useEditingMarkdown();
+  const [inUse, setInUse] = useState<any>({});
+
+  // Update inUse when either of attachments or markdown is updated
+  useEffect(() => {
+    if (markdown == null) {
+      return;
+    }
+
+    const newInUse = {};
+
+    for (const attachment of attachments) {
+      newInUse[attachment._id] = checkIfFileInUse(markdown, attachment);
+    }
+
+    setInUse(newInUse);
+  }, [attachments, markdown]);
+
+  return inUse;
+};
+
+const PageAttachment = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  // Static SWRs
+  const { data: pageId } = useCurrentPageId();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  // States
+  const [pageNumber, setPageNumber] = useState(1);
+  const [attachmentToDelete, setAttachmentToDelete] = useState<any>(undefined);
+  const [deleting, setDeleting] = useState(false);
+  const [deleteError, setDeleteError] = useState('');
+
+  // SWRs
+  const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const {
+    attachments = [],
+    totalAttachments = 0,
+    limit,
+  } = dataAttachments ?? {};
+
+  // Custom hooks
+  const inUseAttachments = useInUseAttachments(attachments);
+
+  // Methods
+  const onChangePageHandler = useCallback((newPageNumber: number) => {
+    setPageNumber(newPageNumber);
+  }, []);
+
+  const onAttachmentDeleteClicked = useCallback((attachment) => {
+    setAttachmentToDelete(attachment);
+  }, []);
+
+  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment) => {
+    setDeleting(true);
+
+    try {
+      await remove({ attachment_id: attachment._id });
+
+      setAttachmentToDelete(null);
+      setDeleting(false);
+    }
+    catch {
+      setDeleteError('Something went wrong.');
+      setDeleting(false);
+    }
+  }, [remove]);
+
+  const onToggleHandler = useCallback(() => {
+    setAttachmentToDelete(null);
+    setDeleteError('');
+  }, []);
+
+  // Renderers
+  const renderDeleteAttachmentModal = useCallback(() => {
+    if (isGuestUser) {
+      return <></>;
+    }
+
+    if (attachments.length === 0) {
+      return (
+        <div data-testid="page-attachment">
+          {t('No_attachments_yet')}
+        </div>
+      );
+    }
+
+    let deleteInUse = null;
+    if (attachmentToDelete != null) {
+      deleteInUse = inUseAttachments[attachmentToDelete._id] || false;
+    }
+
+    const isOpen = attachmentToDelete != null;
+
+    return (
+      <DeleteAttachmentModal
+        isOpen={isOpen}
+        animation="false"
+        toggle={onToggleHandler}
+        attachmentToDelete={attachmentToDelete}
+        inUse={deleteInUse}
+        deleting={deleting}
+        deleteError={deleteError}
+        onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
+      />
+    );
+  // eslint-disable-next-line max-len
+  }, [attachmentToDelete, attachments.length, deleteError, deleting, inUseAttachments, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler, t]);
+
+  return (
+    <div data-testid="page-attachment">
+      <PageAttachmentList
+        attachments={attachments}
+        inUse={inUseAttachments}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={!isGuestUser}
+      />
+
+      {renderDeleteAttachmentModal()}
+
+      <PaginationWrapper
+        activePage={pageNumber}
+        changePage={onChangePageHandler}
+        totalItemsCount={totalAttachments}
+        pagingLimit={limit}
+        align="center"
+      />
+    </div>
+  );
+};
+
+export default PageAttachment;

+ 9 - 8
packages/app/src/components/Theme/ThemeIsland.module.scss

@@ -1,11 +1,12 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
+@use '../../styles/mixins';
 
 $color-primary: #97cbc3;
 $color-themelight: rgba(183, 226, 219, 1);
 
-html[light],
-html[dark] {
+.theme :global {
   $primary: $color-primary;
   // Background colors
   $bgcolor-card: $gray-50;
@@ -81,8 +82,8 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: darken($primary, 15%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   .rbt-menu {
     background: lighten($color-themelight, 5%);
@@ -106,7 +107,7 @@ html[dark] {
   // Button
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager(darken($primary, 50%), lighten($primary, 5%), darken($primary, 5%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager(darken($primary, 50%), lighten($primary, 5%), darken($primary, 5%));
     }
   }
 
@@ -124,7 +125,7 @@ html[dark] {
     // Pagetree
     .grw-pagetree {
       .grw-pagetree-triangle-btn {
-        @include button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
+        @include mixins.button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
       }
     }
   }

+ 8 - 0
packages/app/src/components/Theme/ThemeIsland.tsx

@@ -0,0 +1,8 @@
+import { ThemeInjector } from './utils/ThemeInjector';
+
+import styles from './ThemeIsland.module.scss';
+
+const ThemeIsland = ({ children }: { children: JSX.Element }): JSX.Element => {
+  return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
+};
+export default ThemeIsland;

+ 11 - 10
packages/app/src/components/Theme/ThemeMonoBlue.module.scss

@@ -1,7 +1,8 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
 
-html[light] {
+.theme[data-color-scheme='light'] :global {
   // Theme colors
   $themecolor: #00587a;
   $themelight: #f7fbfd;
@@ -70,8 +71,8 @@ html[light] {
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   // Navs {
   .nav-tabs {
@@ -89,12 +90,12 @@ html[light] {
   // Button
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager($primary, lighten($primary, 65%), lighten($primary, 70%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager($primary, lighten($primary, 65%), lighten($primary, 70%));
     }
   }
 }
 
-html[dark] {
+.theme[data-color-scheme='dark'] :global {
   // Theme colors
   $themecolor: #00587a;
   $themedark: #061f2f;
@@ -167,8 +168,8 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: $primary;
 
-  @import 'apply-colors';
-  @import 'apply-colors-dark';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-dark';
 
   // Navs
   .nav-tabs {
@@ -194,7 +195,7 @@ html[dark] {
   // Button
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager(lighten($primary, 30%), $primary, darken($primary, 10%), darken($primary, 20%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager(lighten($primary, 30%), $primary, darken($primary, 10%), darken($primary, 20%));
     }
   }
 }

+ 8 - 0
packages/app/src/components/Theme/ThemeMonoBlue.tsx

@@ -0,0 +1,8 @@
+import { ThemeInjector } from './utils/ThemeInjector';
+
+import styles from './ThemeMonoBlue.module.scss';
+
+const ThemeMonoBlue = ({ children }: { children: JSX.Element }): JSX.Element => {
+  return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
+};
+export default ThemeMonoBlue;

+ 7 - 7
packages/app/src/components/Theme/ThemeNature.module.scss

@@ -1,5 +1,6 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
 
 // == Define Bootstrap theme colors
 //
@@ -35,8 +36,7 @@ $themecolor: #12b105;
 
 //== Light Mode
 //
-html[light],
-html[dark] {
+.theme :global {
   $primary: #460039;
   $light: $gray-100;
 
@@ -90,8 +90,8 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   // Search Top
   .grw-global-search {
@@ -111,7 +111,7 @@ html[dark] {
   // Button
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager($bgcolor-navbar, lighten($bgcolor-navbar, 65%), lighten($bgcolor-navbar, 70%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager($bgcolor-navbar, lighten($bgcolor-navbar, 65%), lighten($bgcolor-navbar, 70%));
     }
   }
 }

+ 8 - 0
packages/app/src/components/Theme/ThemeNature.tsx

@@ -0,0 +1,8 @@
+import { ThemeInjector } from './utils/ThemeInjector';
+
+import styles from './ThemeNature.module.scss';
+
+const ThemeNature = ({ children }: { children: JSX.Element }): JSX.Element => {
+  return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
+};
+export default ThemeNature;

+ 9 - 8
packages/app/src/components/Theme/ThemeSpring.module.scss

@@ -1,5 +1,7 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
+@use '../../styles/bootstrap/init' as bs;
 
 // == Define Bootstrap theme colors
 //
@@ -25,8 +27,7 @@ $accentcolor: #e08dbc;
 
 //== Light Mode
 //
-html[light],
-html[dark] {
+.theme :global {
   $primary: $themecolor;
   $secondary: $accentcolor;
 
@@ -90,17 +91,17 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: darken($primary, 20%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   //Button
   // Outline buttons are applyed the accent color to this spring theme cuz the primary is too light and it looks like unable to click them.
   .btn.btn-outline-primary {
-    @include button-outline-variant($accentcolor, $accentcolor, lighten($accentcolor, 20%), $accentcolor);
+    @include bs.button-outline-variant($accentcolor, $accentcolor, lighten($accentcolor, 20%), $accentcolor);
   }
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager(darken($primary, 50%), lighten($primary, 5%), lighten($primary, 10%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager(darken($primary, 50%), lighten($primary, 5%), lighten($primary, 10%));
     }
   }
 

+ 8 - 0
packages/app/src/components/Theme/ThemeSpring.tsx

@@ -0,0 +1,8 @@
+import { ThemeInjector } from './utils/ThemeInjector';
+
+import styles from './ThemeSpring.module.scss';
+
+const ThemeSpring = ({ children }: { children: JSX.Element }): JSX.Element => {
+  return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
+};
+export default ThemeSpring;

+ 7 - 7
packages/app/src/components/Theme/ThemeWood.module.scss

@@ -1,5 +1,6 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
 
 // == Define Bootstrap theme colors
 //
@@ -36,8 +37,7 @@ $themelight: #f5f3ee;
 
 //== Light Mode
 //
-html[light],
-html[dark] {
+.theme :global {
   $primary: #aaa45f;
 
   // Background colors
@@ -114,8 +114,8 @@ html[dark] {
   // portal
   $info: lighten($themecolor, 10%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   /*
    * Modal
@@ -164,7 +164,7 @@ html[dark] {
   // Button
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager(darken($primary, 30%), lighten($primary, 15%), lighten($primary, 25%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager(darken($primary, 30%), lighten($primary, 15%), lighten($primary, 25%));
     }
   }
 }

+ 8 - 0
packages/app/src/components/Theme/ThemeWood.tsx

@@ -0,0 +1,8 @@
+import { ThemeInjector } from './utils/ThemeInjector';
+
+import styles from './ThemeWood.module.scss';
+
+const ThemeWood = ({ children }: { children: JSX.Element }): JSX.Element => {
+  return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
+};
+export default ThemeWood;

+ 15 - 0
packages/app/src/components/Theme/utils/ThemeProvider.tsx

@@ -11,6 +11,11 @@ const ThemeBlackboard = dynamic(() => import('../ThemeBlackboard'));
 const ThemeChristmas = dynamic(() => import('../ThemeChristmas'));
 const ThemeDefault = dynamic(() => import('../ThemeDefault'));
 const ThemeJadeGreen = dynamic(() => import('../ThemeJadeGreen'));
+const ThemeIsland = dynamic(() => import('../ThemeIsland'));
+const ThemeSpring = dynamic(() => import('../ThemeSpring'));
+const ThemeNature = dynamic(() => import('../ThemeNature'));
+const ThemeWood = dynamic(() => import('../ThemeWood'));
+const ThemeMonoBlue = dynamic(() => import('../ThemeMonoBlue'));
 
 
 type Props = {
@@ -28,6 +33,16 @@ export const ThemeProvider = ({ theme, children }: Props): JSX.Element => {
       return <ThemeChristmas>{children}</ThemeChristmas>;
     case GrowiThemes.JADE_GREEN:
       return <ThemeJadeGreen>{children}</ThemeJadeGreen>;
+    case GrowiThemes.ISLAND:
+      return <ThemeIsland>{children}</ThemeIsland>;
+    case GrowiThemes.SPRING:
+      return <ThemeSpring>{children}</ThemeSpring>;
+    case GrowiThemes.NATURE:
+      return <ThemeNature>{children}</ThemeNature>;
+    case GrowiThemes.WOOD:
+      return <ThemeWood>{children}</ThemeWood>;
+    case GrowiThemes.MONO_BLUE:
+      return <ThemeMonoBlue>{children}</ThemeMonoBlue>;
     default:
       return <ThemeDefault>{children}</ThemeDefault>;
   }

+ 10 - 1
packages/app/src/interfaces/attachment.ts

@@ -1 +1,10 @@
-export type { IAttachment } from '@growi/core';
+import type { IAttachment } from '@growi/core';
+
+import type { PaginateResult } from './mongoose-utils';
+
+
+export type IResAttachmentList = {
+  data: {
+    paginateResult: PaginateResult<IAttachment>
+  }
+};

+ 2 - 1
packages/app/src/pages/[[...path]].page.tsx

@@ -58,7 +58,7 @@ import {
   useHackmdUri,
   useIsAclEnabled, useIsUserPage, useIsNotCreatable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
-  useIsSlackConfigured, useIsBlinkedHeaderAtBoot, useRendererConfig,
+  useIsSlackConfigured, useIsBlinkedHeaderAtBoot, useRendererConfig, useEditingMarkdown,
 } from '../stores/context';
 import { useXss } from '../stores/xss';
 
@@ -246,6 +246,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pageWithMeta?.data.path ?? '')); // TODO: need to include props.isIdentical
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPathname(props.currentPathname);
+  useEditingMarkdown(pageWithMeta?.data.revision.body);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {

+ 3 - 3
packages/app/src/server/routes/apiv3/attachment.js

@@ -28,7 +28,7 @@ module.exports = (crowi) => {
   const validator = {
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('page').optional().isInt().withMessage('page must be a number'),
+      query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
   };
@@ -53,8 +53,8 @@ module.exports = (crowi) => {
   router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
 
     const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
-    const page = req.query.page || 1;
-    const offset = (page - 1) * limit;
+    const pageNumber = req.query.pageNumber || 1;
+    const offset = (pageNumber - 1) * limit;
 
     try {
       const pageId = req.query.pageId;

+ 52 - 0
packages/app/src/stores/attachment.tsx

@@ -0,0 +1,52 @@
+import { useCallback } from 'react';
+
+import {
+  IAttachment, Nullable, SWRResponseWithUtils, withUtils,
+} from '@growi/core';
+import useSWR from 'swr';
+
+import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { IResAttachmentList } from '~/interfaces/attachment';
+
+type Util = {
+  remove(body: { attachment_id: string }): Promise<void>
+};
+
+type IDataAttachmentList = {
+  attachments: IAttachment[]
+  totalAttachments: number
+  limit: number
+};
+
+export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
+  const shouldFetch = pageId != null && pageNumber != null;
+
+  const fetcher = useCallback(async(endpoint) => {
+    const res = await apiGet<IResAttachmentList>(endpoint, { pageId, pageNumber });
+    return {
+      attachments: res.data.paginateResult.docs,
+      totalAttachments: res.data.paginateResult.totalDocs,
+      limit: res.data.paginateResult.limit,
+    };
+  }, [pageId, pageNumber]);
+
+  const swrResponse = useSWR(
+    shouldFetch ? ['/attachments/list', pageId, pageNumber] : null,
+    fetcher,
+  );
+
+  // Utils
+  const remove = useCallback(async(body: { attachment_id: string }) => {
+    const { mutate } = swrResponse;
+
+    try {
+      await apiPost('/attachments.remove', body);
+      mutate();
+    }
+    catch (err) {
+      throw err;
+    }
+  }, [swrResponse]);
+
+  return withUtils<Util, IDataAttachmentList, Error>(swrResponse, { remove });
+};

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -232,6 +232,10 @@ export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boo
   return useStaticSWR('isBlinkedAtBoot', initialData);
 };
 
+export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('currentMarkdown', initialData);
+};
+
 
 /** **********************************************************
  *                     Computed contexts

+ 1 - 0
packages/core/src/index.ts

@@ -27,3 +27,4 @@ export * from './service/localstorage-manager';
 export * from './utils/basic-interceptor';
 export * from './utils/browser-utils';
 export * from './utils/mongoose-utils';
+export * from './utils/with-utils';