Kaynağa Gözat

Merge branch 'master' into fix/20-basic-features

Shun Miyazawa 3 yıl önce
ebeveyn
işleme
30d06555e3
67 değiştirilmiş dosya ile 1077 ekleme ve 585 silme
  1. 1 0
      packages/app/package.json
  2. 2 19
      packages/app/src/client/services/activate-plugin.ts
  3. 28 0
      packages/app/src/client/services/page-operation.ts
  4. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  5. 0 103
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  6. 67 0
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.tsx
  7. 22 10
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  8. 0 47
      packages/app/src/components/Admin/Customize/ThemeColorBox.jsx
  9. 42 0
      packages/app/src/components/Admin/Customize/ThemeColorBox.tsx
  10. 2 2
      packages/app/src/components/Layout/Admin.module.scss
  11. 0 41
      packages/app/src/components/NotAvailableForGuest.jsx
  12. 35 0
      packages/app/src/components/NotAvailableForGuest.tsx
  13. 6 2
      packages/app/src/components/Page.tsx
  14. 1 1
      packages/app/src/components/Page/RenderTagLabels.tsx
  15. 1 1
      packages/app/src/components/PageAlert/PageRedirectedAlert.tsx
  16. 14 4
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  17. 1 1
      packages/app/src/components/PageComment/CommentEditor.tsx
  18. 31 41
      packages/app/src/components/PageEditor.tsx
  19. 3 2
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  20. 35 43
      packages/app/src/components/PageEditorByHackmd.tsx
  21. 3 0
      packages/app/src/components/PageStatusAlert.tsx
  22. 10 5
      packages/app/src/components/SavePageControls.tsx
  23. 4 2
      packages/app/src/components/Sidebar.tsx
  24. 12 0
      packages/app/src/components/Sidebar/CustomSidebar.module.scss
  25. 11 15
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  26. 22 20
      packages/app/src/components/Sidebar/PageTree.tsx
  27. 21 21
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  28. 14 1
      packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss
  29. 4 2
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  30. 17 0
      packages/app/src/components/Sidebar/RecentChanges.module.scss
  31. 43 67
      packages/app/src/components/Sidebar/RecentChanges.tsx
  32. 14 0
      packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx
  33. 18 0
      packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx
  34. 18 0
      packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx
  35. 40 0
      packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx
  36. 6 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss
  37. 50 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx
  38. 23 0
      packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx
  39. 10 0
      packages/app/src/components/Sidebar/Tag.module.scss
  40. 5 10
      packages/app/src/components/Sidebar/Tag.tsx
  41. 1 1
      packages/app/src/components/Skeleton.tsx
  42. 4 1
      packages/app/src/interfaces/customize.ts
  43. 8 2
      packages/app/src/interfaces/plugin.ts
  44. 0 19
      packages/app/src/interfaces/theme.ts
  45. 56 54
      packages/app/src/pages/_document.page.tsx
  46. 1 1
      packages/app/src/pages/admin/customize.page.tsx
  47. 1 1
      packages/app/src/pages/admin/plugins.page.tsx
  48. 2 3
      packages/app/src/server/models/config.ts
  49. 31 3
      packages/app/src/server/models/growi-plugin.ts
  50. 13 2
      packages/app/src/server/routes/apiv3/customize-setting.js
  51. 1 0
      packages/app/src/server/routes/index.js
  52. 112 7
      packages/app/src/server/service/plugin.ts
  53. 3 2
      packages/app/src/server/views/widget/page_alerts.html
  54. 38 6
      packages/app/src/services/renderer/renderer.tsx
  55. 12 6
      packages/app/src/stores/admin/customize.tsx
  56. 0 1
      packages/app/src/stores/context.tsx
  57. 21 0
      packages/app/src/styles/_mixins.scss
  58. 2 0
      packages/core/src/index.ts
  59. 17 0
      packages/core/src/interfaces/growi-theme-metadata.ts
  60. 10 0
      packages/core/src/interfaces/vite.ts
  61. 0 1
      packages/preset-themes/.eslintrc.js
  62. 82 0
      packages/preset-themes/src/consts/preset-themes.ts
  63. 3 2
      packages/preset-themes/src/index.ts
  64. 17 0
      packages/preset-themes/src/interfaces/growi-theme-metadata.ts
  65. 0 7
      packages/preset-themes/src/interfaces/manifest.ts
  66. 0 5
      packages/preset-themes/src/utils/index.ts
  67. 5 0
      yarn.lock

+ 1 - 0
packages/app/package.json

@@ -158,6 +158,7 @@
     "react-bootstrap-typeahead": "^5.2.2",
     "react-card-flip": "^1.0.10",
     "react-datepicker": "^4.7.0",
+    "react-disable": "^0.1.1",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",

+ 2 - 19
packages/app/src/client/services/activate-plugin.ts

@@ -1,9 +1,5 @@
-import { readFileSync } from 'fs';
-import path from 'path';
-
-import { GrowiPlugin } from '~/interfaces/plugin';
 import { initializeGrowiFacade } from '~/utils/growi-facade';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
+import loggerFactory from '~/utils/logger';
 
 
 declare global {
@@ -16,24 +12,11 @@ declare global {
   };
 }
 
-
-export type GrowiPluginManifestEntries = [growiPlugin: GrowiPlugin, manifest: any][];
+const logger = loggerFactory('growi:cli:ActivatePluginService');
 
 
 export class ActivatePluginService {
 
-  static async retrievePluginManifests(growiPlugins: GrowiPlugin[]): Promise<GrowiPluginManifestEntries> {
-    const entries: GrowiPluginManifestEntries = [];
-
-    growiPlugins.forEach(async(growiPlugin) => {
-      const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
-      const customManifestStr: string = await readFileSync(manifestPath, 'utf-8');
-      entries.push([growiPlugin, JSON.parse(customManifestStr)]);
-    });
-
-    return entries;
-  }
-
   static activateAll(): void {
     initializeGrowiFacade();
 

+ 28 - 0
packages/app/src/client/services/page-operation.ts

@@ -2,7 +2,10 @@ import { SubscriptionStatusType, Nullable } from '@growi/core';
 import urljoin from 'url-join';
 
 import { OptionsToSave } from '~/interfaces/page-operation';
+import { useCurrentPageId } from '~/stores/context';
 import { useIsEnabledUnsavedWarning } from '~/stores/editor';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
@@ -171,3 +174,28 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
     return res;
   };
 };
+
+export const useUpdateStateAfterSave = () => {
+  const { mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // update swr 'currentPageId', 'currentPage', remote states
+  return async(pageId: string) => {
+    await mutateCurrentPageId(pageId);
+    const updatedPage = await mutateCurrentPage();
+
+    if (updatedPage == null) { return }
+
+    const remoterevisionData = {
+      remoteRevisionId: updatedPage.revision._id,
+      remoteRevisionBody: updatedPage.revision.body,
+      remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
+      remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
+      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced?.toString(),
+      hasDraftOnHackmd: updatedPage.hasDraftOnHackmd,
+    };
+
+    setRemoteLatestPageData(remoterevisionData);
+  };
+};

+ 1 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -36,7 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
-      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{           'Plugins Extension'}</>;
+      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          'Plugins'}</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */

+ 0 - 103
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -1,103 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { GrowiThemes } from '~/interfaces/theme';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import ThemeColorBox from './ThemeColorBox';
-
-/* eslint-disable no-multi-spaces */
-const lightNDarkTheme = [{
-  name: GrowiThemes.DEFAULT,      bg: '#ffffff', topbar: '#2a2929', sidebar: '#122c55', theme: '#209fd8',
-}, {
-  name: GrowiThemes.MONO_BLUE,    bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
-}, {
-  name: GrowiThemes.HUFFLEPUFF,   bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
-}, {
-  name: GrowiThemes.FIRE_RED,     bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#EA5532',
-}, {
-  name: GrowiThemes.JADE_GREEN,   bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#38B48B',
-}];
-
-const uniqueTheme = [{
-  name: GrowiThemes.NATURE,       bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', theme: '#460039',
-}, {
-  name: GrowiThemes.WOOD,         bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', theme: '#aaa45f',
-}, {
-  name: GrowiThemes.ISLAND,       bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', theme: 'rgba(183, 226, 219, 1)',
-}, {
-  name: GrowiThemes.CHRISTMAS,    bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', theme: '#d3c665',
-}, {
-  name: GrowiThemes.ANTARCTIC,    bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', theme: '#fa9913',
-}, {
-  name: GrowiThemes.SPRING,       bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', theme: '#67a856',
-}, {
-  name: GrowiThemes.FUTURE,       bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
-}, {
-  name: GrowiThemes.HALLOWEEN,    bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
-}, {
-  name: GrowiThemes.KIBELA,       bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
-}, {
-  name: GrowiThemes.BLACKBOARD,   bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', theme: '#DA8506',
-}];
-
-
-const CustomizeThemeOptions = (props) => {
-
-  const { selectedTheme } = props;
-
-  const { t } = useTranslation('admin');
-
-
-  return (
-    <div id="themeOptions">
-      {/* Light and Dark Themes */}
-      <div>
-        <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
-        <div className="d-flex flex-wrap">
-          {lightNDarkTheme.map((theme) => {
-            return (
-              <ThemeColorBox
-                key={theme.name}
-                isSelected={selectedTheme === theme.name}
-                onSelected={() => props.onSelected(theme.name)}
-                {...theme}
-              />
-            );
-          })}
-        </div>
-      </div>
-      {/* Unique Theme */}
-      <div className="mt-3">
-        <h3>{t('customize_settings.theme_desc.unique')}</h3>
-        <div className="d-flex flex-wrap">
-          {uniqueTheme.map((theme) => {
-            return (
-              <ThemeColorBox
-                key={theme.name}
-                isSelected={selectedTheme === theme.name}
-                onSelected={() => props.onSelected(theme.name)}
-                {...theme}
-              />
-            );
-          })}
-        </div>
-      </div>
-    </div>
-  );
-
-};
-
-const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOptions, [AdminCustomizeContainer]);
-
-CustomizeThemeOptions.propTypes = {
-  onSelected: PropTypes.func,
-  selectedTheme: PropTypes.string,
-};
-
-export default CustomizeThemeOptionsWrapper;

+ 67 - 0
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.tsx

@@ -0,0 +1,67 @@
+import React, { useMemo } from 'react';
+
+import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import { ThemeColorBox } from './ThemeColorBox';
+
+
+type Props = {
+  availableThemes: GrowiThemeMetadata[],
+  selectedTheme?: string,
+  onSelected?: (themeName: string) => void,
+};
+
+const CustomizeThemeOptions = (props: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const { availableThemes, selectedTheme, onSelected } = props;
+
+  const lightNDarkThemes = useMemo(() => {
+    return availableThemes.filter(s => s.schemeType === GrowiThemeSchemeType.BOTH);
+  }, [availableThemes]);
+  const oneModeThemes = useMemo(() => {
+    return availableThemes.filter(s => s.schemeType !== GrowiThemeSchemeType.BOTH);
+  }, [availableThemes]);
+
+  return (
+    <div id="themeOptions">
+      {/* Light and Dark Themes */}
+      <div>
+        <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
+        <div className="d-flex flex-wrap">
+          {lightNDarkThemes.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={selectedTheme != null && selectedTheme === theme.name}
+                metadata={theme}
+                onSelected={() => onSelected?.(theme.name)}
+              />
+            );
+          })}
+        </div>
+      </div>
+      {/* Only one mode Theme */}
+      <div className="mt-3">
+        <h3>{t('customize_settings.theme_desc.unique')}</h3>
+        <div className="d-flex flex-wrap">
+          {oneModeThemes.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={selectedTheme != null && selectedTheme === theme.name}
+                metadata={theme}
+                onSelected={() => onSelected?.(theme.name)}
+              />
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+
+};
+
+
+export default CustomizeThemeOptions;

+ 22 - 10
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -1,15 +1,17 @@
 import React, { useCallback, useEffect, useState } from 'react';
 
+import { PresetThemes, PresetThemesMetadatas } from '@growi/preset-themes';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
-import { useSWRxGrowiTheme } from '~/stores/admin/customize';
+import { useSWRxGrowiThemeSetting } from '~/stores/admin/customize';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 
+
 // eslint-disable-next-line @typescript-eslint/ban-types
 type Props = {
 }
@@ -18,26 +20,26 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: currentTheme, error } = useSWRxGrowiTheme();
-  const [selectedTheme, setSelectedTheme] = useState(currentTheme);
+  const { data, error } = useSWRxGrowiThemeSetting();
+  const [currentTheme, setCurrentTheme] = useState(data?.currentTheme);
 
   useEffect(() => {
-    setSelectedTheme(currentTheme);
-  }, [currentTheme]);
+    setCurrentTheme(data?.currentTheme);
+  }, [data?.currentTheme]);
 
   const selectedHandler = useCallback((themeName: string) => {
-    setSelectedTheme(themeName);
+    setCurrentTheme(themeName);
   }, []);
 
   const submitHandler = useCallback(async() => {
-    if (selectedTheme == null) {
+    if (currentTheme == null) {
       toastWarning('The selected theme is undefined');
       return;
     }
 
     try {
       await apiv3Put('/customize-setting/theme', {
-        theme: selectedTheme,
+        theme: currentTheme,
       });
 
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.theme'), ns: 'commons' }));
@@ -45,13 +47,23 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [selectedTheme, t]);
+  }, [currentTheme, t]);
+
+  const availableThemes = data?.pluginThemesMetadatas == null
+    ? PresetThemesMetadatas
+    : PresetThemesMetadatas.concat(data.pluginThemesMetadatas);
+
+  const selectedTheme = availableThemes.find(t => t.name === currentTheme)?.name ?? PresetThemes.DEFAULT;
 
   return (
     <div className="row">
       <div className="col-12">
         <h2 className="admin-setting-header">{t('admin:customize_settings.theme')}</h2>
-        <CustomizeThemeOptions onSelected={selectedHandler} selectedTheme={selectedTheme} />
+        <CustomizeThemeOptions
+          onSelected={selectedHandler}
+          availableThemes={availableThemes}
+          selectedTheme={selectedTheme}
+        />
         <AdminUpdateButtonRow onClick={submitHandler} disabled={error != null} />
       </div>
     </div>

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

@@ -1,47 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-
-class ThemeColorBox extends React.PureComponent {
-
-  render() {
-    const {
-      isSelected, onSelected, name, bg, topbar, sidebar, theme,
-    } = this.props;
-
-    return (
-      <div
-        id={`theme-option-${name}`}
-        className={`theme-option-container d-flex flex-column align-items-center ${isSelected && 'active'}`}
-        onClick={onSelected}
-      >
-        <a id={name} role="button" className={`m-0 ${name} theme-button`}>
-          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-            <g>
-              <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={bg}></path>
-              <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill={topbar}></path>
-              <path d="M -1 15 L15 15 L15 65 L-1 65 L-1 15 Z" fill={sidebar}></path>
-              <path d="M 65 45 L65 65 L45 65 L65 45 Z" fill={theme}></path>
-            </g>
-          </svg>
-        </a>
-        <span className="theme-option-name"><b>{ name }</b></span>
-      </div>
-    );
-  }
-
-}
-
-
-ThemeColorBox.propTypes = {
-  isSelected: PropTypes.bool.isRequired,
-  onSelected: PropTypes.func.isRequired,
-  name: PropTypes.string.isRequired,
-  bg: PropTypes.string.isRequired,
-  topbar: PropTypes.string.isRequired,
-  sidebar: PropTypes.string.isRequired,
-  theme: PropTypes.string.isRequired,
-};
-
-export default ThemeColorBox;

+ 42 - 0
packages/app/src/components/Admin/Customize/ThemeColorBox.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import type { GrowiThemeMetadata } from '@growi/core';
+
+
+type Props = {
+  isSelected: boolean,
+  metadata: GrowiThemeMetadata,
+  onSelected?: () => void,
+};
+
+export const ThemeColorBox = (props: Props): JSX.Element => {
+
+  const {
+    isSelected, metadata, onSelected,
+  } = props;
+  const {
+    name, bg, topbar, sidebar, accent, isPresetTheme,
+  } = metadata;
+
+  return (
+    <div
+      id={`theme-option-${name}`}
+      className={`theme-option-container d-flex flex-column align-items-center ${isSelected ? 'active' : ''}`}
+      onClick={onSelected}
+    >
+      <a id={name} role="button" className={`m-0 ${name} theme-button`}>
+        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+          <g>
+            <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={bg}></path>
+            <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill={topbar}></path>
+            <path d="M -1 15 L15 15 L15 65 L-1 65 L-1 15 Z" fill={sidebar}></path>
+            <path d="M 65 45 L65 65 L45 65 L65 45 Z" fill={accent}></path>
+          </g>
+        </svg>
+      </a>
+      <span className="theme-option-name"><b>{ name }</b></span>
+      { !isPresetTheme && <span className='theme-option-badge badge badge-primary mt-1'>Plugin</span> }
+    </div>
+  );
+
+};

+ 2 - 2
packages/app/src/components/Layout/Admin.module.scss

@@ -267,12 +267,12 @@ $slack-work-space-name-card-border: #efc1f6;
       background-color: $gray-50;
       border: 1px solid $border-color;
     }
-    .theme-option-name {
+    .theme-option-name, .theme-option-badge {
       opacity: 0.3;
     }
     // style (active)
     .theme-option-container.active {
-      .theme-option-name {
+      .theme-option-name, .theme-option-badge {
         opacity: 1;
       }
     }

+ 0 - 41
packages/app/src/components/NotAvailableForGuest.jsx

@@ -1,41 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { useIsGuestUser } from '~/stores/context';
-
-const NotAvailableForGuest = (props) => {
-  const { children } = props;
-  const { t } = useTranslation();
-
-  const { data: isGuestUser } = useIsGuestUser();
-
-  if (!isGuestUser) {
-    return props.children;
-  }
-
-  const id = children.props.id || `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
-
-  // clone and add className
-  const clonedChild = React.cloneElement(children, {
-    id,
-    className: `${children.props.className} grw-not-available-for-guest`,
-    onClick: () => { /* do nothing */ },
-  });
-
-  return (
-    <>
-      { clonedChild }
-      <UncontrolledTooltip placement="top" target={id}>{t('Not available for guest')}</UncontrolledTooltip>
-    </>
-  );
-
-};
-
-NotAvailableForGuest.propTypes = {
-  children: PropTypes.node.isRequired,
-};
-
-export default NotAvailableForGuest;

+ 35 - 0
packages/app/src/components/NotAvailableForGuest.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Disable } from 'react-disable';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { useIsGuestUser } from '~/stores/context';
+
+type NotAvailableForGuestProps = {
+  children: JSX.Element
+}
+
+export const NotAvailableForGuest = ({ children }: NotAvailableForGuestProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const isDisabled = !!isGuestUser;
+
+  if (!isGuestUser) {
+    return children;
+  }
+
+  const id = `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
+
+  return (
+    <>
+      <div id={id}>
+        <Disable disabled={isDisabled}>
+          { children }
+        </Disable>
+      </div>
+      <UncontrolledTooltip placement="top" target={id}>{t('Not available for guest')}</UncontrolledTooltip>
+    </>
+  );
+};

+ 6 - 2
packages/app/src/components/Page.tsx

@@ -5,6 +5,7 @@ import React, {
 
 import EventEmitter from 'events';
 
+import { pagePathUtils } from '@growi/core';
 import { DrawioEditByViewerProps } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -15,7 +16,7 @@ import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import {
-  useIsGuestUser, useShareLinkId,
+  useIsGuestUser, useShareLinkId, useCurrentPathname,
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useDrawioModal, useHandsontableModal } from '~/stores/modal';
@@ -59,10 +60,13 @@ export const Page = (props) => {
     tocRef.current = toc;
   }, []);
 
+  const { data: currentPathname } = useCurrentPathname();
+  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
+
   const { data: shareLinkId } = useShareLinkId();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
+  const { data: tagsInfo } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);

+ 1 - 1
packages/app/src/components/Page/RenderTagLabels.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import NotAvailableForGuest from '../NotAvailableForGuest';
+import { NotAvailableForGuest } from '../NotAvailableForGuest';
 
 type RenderTagLabelsProps = {
   tags: string[],

+ 1 - 1
packages/app/src/components/PageAlert/PageRedirectedAlert.tsx

@@ -21,7 +21,7 @@ export const PageRedirectedAlert = React.memo((): JSX.Element => {
     }
   }, [unlink]);
 
-  if (redirectFrom == null) {
+  if (redirectFrom == null || redirectFrom === '') {
     return <></>;
   }
 

+ 14 - 4
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -5,10 +5,13 @@ import { format } from 'date-fns';
 import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
+import { toastError } from '~/client/util/apiNotification';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage } from '~/stores/page';
+import { useRedirectFrom } from '~/stores/page-redirect';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
+
 const onDeletedHandler = (pathOrPathsToDelete) => {
   if (typeof pathOrPathsToDelete !== 'string') {
     return;
@@ -31,6 +34,7 @@ export const TrashPageAlert = (): JSX.Element => {
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
+  const { unlink } = useRedirectFrom();
 
 
   const deleteUser = pageData?.deleteUser;
@@ -43,12 +47,18 @@ export const TrashPageAlert = (): JSX.Element => {
       return;
     }
     const putBackedHandler = () => {
-      // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
-      // See: https://github.com/weseek/growi/pull/7054
-      router.reload();
+      try {
+        unlink();
+        // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
+        // See: https://github.com/weseek/growi/pull/7054
+        router.reload();
+      }
+      catch (err) {
+        toastError(err);
+      }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [openPutBackPageModal, pageId, pagePath, router]);
+  }, [openPutBackPageModal, pageId, pagePath, router, unlink]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -20,7 +20,7 @@ import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
-import NotAvailableForGuest from '../NotAvailableForGuest';
+import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import Editor from '../PageEditor/Editor';
 
 

+ 31 - 41
packages/app/src/components/PageEditor.tsx

@@ -13,7 +13,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IEditorMethods } from '~/interfaces/editor-methods';
@@ -31,6 +31,7 @@ import {
 } from '~/stores/editor';
 import { useConflictDiffModal } from '~/stores/modal';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
@@ -68,7 +69,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const router = useRouter();
 
   const { data: isNotFound } = useIsNotFound();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -81,7 +82,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
@@ -93,6 +93,8 @@ const PageEditor = React.memo((): JSX.Element => {
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
 
+  const updateStateAfterSave = useUpdateStateAfterSave();
+
   const currentRevisionId = currentPage?.revision?._id;
 
   const initialValue = useMemo(() => {
@@ -114,8 +116,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
-  const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
-
   const { data: socket } = useGlobalSocket();
 
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -149,18 +149,21 @@ const PageEditor = React.memo((): JSX.Element => {
 
   }, [socket, checkIsConflict]);
 
-  // const optionsToSave = useMemo(() => {
-  //   if (grantData == null) {
-  //     return;
-  //   }
-  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
-  //   const optionsToSave = getOptionsToSave(
-  //     isSlackEnabled ?? false, slackChannels,
-  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
-  //     pageTags || [],
-  //   );
-  //   return optionsToSave;
-  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   // register to facade
   useEffect(() => {
     // for markdownRenderer
@@ -186,30 +189,19 @@ const PageEditor = React.memo((): JSX.Element => {
     setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
 
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
-    if (grantData == null || isSlackEnabled == null || currentPathname == null) {
+  const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
+    if (currentPathname == null || optionsToSave == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
     }
 
-    const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
-    const grantedGroup = grantData?.grantedGroup;
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled,
-      slackChannels,
-      grant: grant || 1,
-      pageTags: pageTags || [],
-      grantUserGroupId: grantedGroup?.id,
-      grantUserGroupName: grantedGroup?.name,
-      ...opts,
-    };
+    const options = Object.assign(optionsToSave, opts);
 
     try {
       const { page } = await saveOrUpdate(
         markdownToSave.current,
         { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
-        optionsToSave,
+        options,
       );
 
       return page;
@@ -229,9 +221,9 @@ const PageEditor = React.memo((): JSX.Element => {
     }
 
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
+  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
 
-  const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+  const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
       return;
     }
@@ -245,11 +237,10 @@ const PageEditor = React.memo((): JSX.Element => {
       await router.push(`/${page._id}`);
     }
     else {
-      await mutateCurrentPageId(page._id);
-      await mutateCurrentPage();
+      updateStateAfterSave(page._id);
     }
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
+  }, [editorMode, save, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
@@ -258,11 +249,10 @@ const PageEditor = React.memo((): JSX.Element => {
 
     const page = await save();
     if (page != null) {
+      updateStateAfterSave(page._id);
       toastSuccess(t('toaster.save_succeeded'));
-      await mutateCurrentPageId(page._id);
-      await mutateCurrentPage();
     }
-  }, [editorMode, mutateCurrentPage, mutateCurrentPageId, save, t]);
+  }, [editorMode, save, t, updateStateAfterSave]);
 
 
   /**
@@ -539,7 +529,7 @@ const PageEditor = React.memo((): JSX.Element => {
         isOpen={conflictDiffModalStatus?.isOpened}
         onClose={() => closeConflictDiffModal()}
         markdownOnEdit={markdownToPreview}
-        optionsToSave={undefined} // replace undefined
+        optionsToSave={optionsToSave}
         afterResolvedHandler={afterResolvedHandler}
       />
     </div>

+ 3 - 2
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 
 
+import { SavePageControlsProps } from '~/components/SavePageControls';
 import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
@@ -12,7 +13,7 @@ import {
 } from '~/stores/ui';
 
 
-const SavePageControls = dynamic(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
+const SavePageControls = dynamic<SavePageControlsProps>(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
 const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
 const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
@@ -129,7 +130,7 @@ const EditorNavbarBottom = (): JSX.Element => {
               )}
             </div>
           ))}
-          <SavePageControls />
+          <SavePageControls slackChannels={slackChannelsStr} />
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
       </div>

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

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useRef, useState, useEffect,
+  useCallback, useRef, useState, useEffect, useMemo,
 } from 'react';
 
 import EventEmitter from 'events';
@@ -10,7 +10,7 @@ import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
@@ -19,7 +19,7 @@ import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+  useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
@@ -56,12 +56,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
-  const { data: grant } = useSelectedGrant();
+  const { data: grantData } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const saveOrUpdate = useSaveOrUpdate();
 
@@ -71,8 +70,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
 
-  const slackChannels = slackChannelsData?.toString();
-
   const [isInitialized, setIsInitialized] = useState(false);
   const [isInitializing, setIsInitializing] = useState(false);
   // for error
@@ -86,33 +83,40 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
-  const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
+  const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
+
+  const updateStateAfterSave = useUpdateStateAfterSave();
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.HackMD) { return }
 
     try {
-      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null
-          || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null) {
+      if (currentPathname == null || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null || optionsToSave == null) {
         throw new Error('Some materials to save are invalid');
       }
 
-      const optionsToSave: OptionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-        ...opts,
-      };
+      const options = Object.assign(optionsToSave, opts, { isSyncRevisionToHackmd: true });
 
       const markdown = await hackmdEditorRef.current.getValue();
 
-      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, options);
       await mutatePageData();
       await mutateTagsInfo();
 
@@ -123,8 +127,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
         await router.push(`/${page._id}`);
       }
       else {
-        await mutateCurrentPageId(page._id);
-        await mutatePageData();
+        updateStateAfterSave(page._id);
       }
       setIsInitialized(false);
       mutateEditorMode(EditorMode.View);
@@ -134,7 +137,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
     }
   // eslint-disable-next-line max-len
-  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, revisionIdHackmdSynced, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
+  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -237,27 +240,18 @@ export const PageEditorByHackmd = (): JSX.Element => {
     try {
       const currentPagePathOrPathname = currentPagePath || currentPathname;
       if (
-        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
-        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+        pageId == null || revisionIdHackmdSynced == null || currentPagePathOrPathname == null || optionsToSave == null
       ) { throw new Error('Some materials to save are invalid') }
-      const optionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-      };
-      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+
+      const options = Object.assign(optionsToSave, { isSyncRevisionToHackmd: true });
+
+      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, options);
 
       // update pageData
       mutatePageData(res);
 
       // set updated data
-      mutateRemoteRevisionId(res.revision._id);
-      mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
-      mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
+      updateStateAfterSave(res._id);
       mutateTagsInfo();
 
       logger.debug('success to save');
@@ -268,9 +262,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [
-    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags,
-    saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
+  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, t]);
 
   /**
    * onChange event of HackmdEditor handler

+ 3 - 0
packages/app/src/components/PageStatusAlert.tsx

@@ -119,6 +119,9 @@ export const PageStatusAlert = (): JSX.Element => {
     const isRevisionOutdated = revision?._id !== remoteRevisionId;
     const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
+    // 'revision?._id' and 'remoteRevisionId' are can not be undefined
+    if (revision?._id == null || remoteRevisionId == null) { return }
+
     // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
       return getContentsForUpdatedAlert();

+ 10 - 5
packages/app/src/components/SavePageControls.tsx

@@ -30,7 +30,12 @@ const logger = loggerFactory('growi:SavePageControls');
 
 const { isTopPage } = pagePathUtils;
 
-export const SavePageControls = (): JSX.Element | null => {
+export type SavePageControlsProps = {
+  slackChannels: string
+}
+
+export const SavePageControls = (props: SavePageControlsProps): JSX.Element | null => {
+  const { slackChannels } = props;
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
@@ -45,13 +50,13 @@ export const SavePageControls = (): JSX.Element | null => {
 
   const save = useCallback(async(): Promise<void> => {
     // save
-    globalEmitter.emit('saveAndReturnToView');
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { slackChannels });
+  }, [slackChannels]);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
-    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
+  }, [slackChannels]);
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {

+ 4 - 2
packages/app/src/components/Sidebar.tsx

@@ -17,6 +17,7 @@ import {
 import DrawerToggler from './Navbar/DrawerToggler';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import { SidebarNav } from './Sidebar/SidebarNav';
+import { SidebarSkeleton } from './Sidebar/Skeleton/SidebarSkeleton';
 import { StickyStretchableScrollerProps } from './StickyStretchableScroller';
 
 import styles from './Sidebar.module.scss';
@@ -57,8 +58,9 @@ const GlobalNavigation = () => {
 
 const SidebarContentsWrapper = () => {
   const StickyStretchableScroller = dynamic<StickyStretchableScrollerProps>(() => import('./StickyStretchableScroller')
-    .then(mod => mod.StickyStretchableScroller), { ssr: false });
-  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
+    .then(mod => mod.StickyStretchableScroller), { ssr: false, loading: () => <SidebarSkeleton /> });
+  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents')
+    .then(mod => mod.SidebarContents), { ssr: false, loading: () => <SidebarSkeleton /> });
   const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
   const calcViewHeight = useCallback(() => {

+ 12 - 0
packages/app/src/components/Sidebar/CustomSidebar.module.scss

@@ -1,7 +1,19 @@
 @use '~/styles/organisms/wiki-custom-sidebar.scss';
+@use '~/styles/mixins' as *;
 
 .grw-custom-sidebar-content :global {
   .wiki {
     @extend %grw-custom-sidebar-content;
   }
+
+  .grw-custom-sidebar-skeleton-text {
+    @include grw-skeleton-text($font-size:15px, $line-height:21.42px);
+    max-width: 160px;
+    margin: 15px 0;
+  }
+
+  .grw-custom-sidebar-skeleton-text-full {
+    @extend .grw-custom-sidebar-skeleton-text;
+    max-width: 100%;
+  }
 }

+ 11 - 15
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -10,6 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import CustomSidebarContentSkeleton from './Skeleton/CustomSidebarContentSkeleton';
 
 import styles from './CustomSidebar.module.scss';
 
@@ -19,11 +21,9 @@ const logger = loggerFactory('growi:cli:CustomSidebar');
 
 const SidebarNotFound = () => {
   return (
-    <div className="grw-sidebar-content-header h5 text-center p-3">
+    <div className="grw-sidebar-content-header h5 text-center py-3">
       <Link href="/Sidebar#edit">
-        <a href="/Sidebar#edit">
-          <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
-        </a>
+        <a><i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page</a>
       </Link>
     </div>
   );
@@ -43,28 +43,24 @@ const CustomSidebar: FC = () => {
   const markdown = (page?.revision as IRevision | undefined)?.body;
 
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3 d-flex">
+    <div className="px-3">
+      <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">
           {t('CustomSidebar')}
-          <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
+          <Link href="/Sidebar"><a className="h6 ml-2"><i className="icon-pencil"></i></a></Link>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => mutate()} />
       </div>
 
       {
         isLoading && (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-          </div>
+          <CustomSidebarContentSkeleton />
         )
       }
 
       {
         (!isLoading && markdown != null) && (
-          <div className={`p-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+          <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
             <RevisionRenderer
               rendererOptions={rendererOptions}
               markdown={markdown}
@@ -78,7 +74,7 @@ const CustomSidebar: FC = () => {
           <SidebarNotFound />
         )
       }
-    </>
+    </div>
   );
 };
 

+ 22 - 20
packages/app/src/components/Sidebar/PageTree.tsx

@@ -10,6 +10,17 @@ import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
 import ItemsTree from './PageTree/ItemsTree';
 import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
+import PageTreeContentSkeleton from './Skeleton/PageTreeContentSkeleton';
+
+const PageTreeHeader = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="grw-sidebar-content-header py-3 d-flex">
+      <h3 className="mb-0">{t('Page Tree')}</h3>
+    </div>
+  );
+};
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
@@ -24,14 +35,10 @@ const PageTree: FC = memo(() => {
 
   if (migrationStatus == null) {
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
-        <div className="text-muted text-center mt-3">
-          <i className="fa fa-lg fa-spinner fa-pulse mr-1"></i>
-        </div>
-      </>
+      <div className="px-3">
+        <PageTreeHeader />
+        <PageTreeContentSkeleton />
+      </div>
     );
   }
 
@@ -39,15 +46,13 @@ const PageTree: FC = memo(() => {
     // TODO : improve design
     // Story : https://redmine.weseek.co.jp/issues/83755
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
+      <div className="px-3">
+        <PageTreeHeader />
         <div className="mt-5 mx-2 text-center">
           <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
           <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
         </div>
-      </>
+      </div>
     );
   }
 
@@ -61,11 +66,8 @@ const PageTree: FC = memo(() => {
   const path = currentPath || '/';
 
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3">
-        <h3 className="mb-0">{t('Page Tree')}</h3>
-      </div>
-
+    <div className="px-3">
+      <PageTreeHeader />
       <ItemsTree
         isEnableActions={!isGuestUser}
         targetPath={path}
@@ -74,13 +76,13 @@ const PageTree: FC = memo(() => {
       />
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top p-3 w-100">
+        <div className="grw-pagetree-footer border-top py-3 w-100">
           <div className="private-legacy-pages-link px-3 py-2">
             <PrivateLegacyPagesLink />
           </div>
         </div>
       )}
-    </>
+    </div>
   );
 });
 

+ 21 - 21
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -14,7 +14,7 @@ import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/p
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
-import NotAvailableForGuest from '~/components/NotAvailableForGuest';
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
@@ -482,27 +482,27 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <CountBadge count={descendantCount} />
           </div>
         )}
-        <div className="grw-pagetree-control d-flex">
-          <PageItemControl
-            pageId={page._id}
-            isEnableActions={isEnableActions}
-            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-            onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-            onClickRenameMenuItem={renameMenuItemClickHandler}
-            onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
-            isInstantRename
-            // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-            operationProcessData={page.processData}
-          >
-            {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-              <NotAvailableForGuest>
+        <NotAvailableForGuest>
+          <div className="grw-pagetree-control d-flex">
+            <PageItemControl
+              pageId={page._id}
+              isEnableActions={isEnableActions}
+              onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+              onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+              onClickRenameMenuItem={renameMenuItemClickHandler}
+              onClickDeleteMenuItem={deleteMenuItemClickHandler}
+              onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
+              isInstantRename
+              // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+              operationProcessData={page.processData}
+            >
+              {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
+              <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
                 <i id='option-button-in-page-tree' className="icon-options fa fa-rotate-90 p-1"></i>
-              </NotAvailableForGuest>
-            </DropdownToggle>
-          </PageItemControl>
-        </div>
+              </DropdownToggle>
+            </PageItemControl>
+          </div>
+        </NotAvailableForGuest>
 
         {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
           <NotAvailableForGuest>

+ 14 - 1
packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss

@@ -1,9 +1,22 @@
 @use '~/styles/variables' as var;
+@use '~/styles/mixins' as *;
 $grw-sidebar-content-header-height: 58px;
 $grw-sidebar-content-footer-height: 50px;
 $grw-pagetree-item-padding-left: 10px;
+$grw-pagetree-item-container-height: 40px;
 
 .grw-pagetree {
+
+  .grw-pagetree-item-skeleton-text {
+    @include grw-skeleton-text($font-size:16px, $line-height:$grw-pagetree-item-container-height);
+    padding-left: 12px;
+  }
+
+  .grw-pagetree-item-skeleton-text-child {
+    @extend .grw-pagetree-item-skeleton-text;
+    padding-left: 12px + $grw-pagetree-item-padding-left;
+  }
+
   :global {
     min-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
 
@@ -56,7 +69,7 @@ $grw-pagetree-item-padding-left: 10px;
     .grw-pagetree-item-container {
       .grw-triangle-container {
         min-width: 35px;
-        height: 40px;
+        height: $grw-pagetree-item-container-height;
       }
     }
   }

+ 4 - 2
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -24,6 +24,8 @@ import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
+import PageTreeContentSkeleton from '../Skeleton/PageTreeContentSkeleton';
+
 import Item from './Item';
 import { ItemNode } from './ItemNode';
 
@@ -272,7 +274,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   if (initialItemNode != null) {
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group p-3`} ref={rootElemRef}>
+      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
         <Item
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}
@@ -288,7 +290,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     );
   }
 
-  return <></>;
+  return <PageTreeContentSkeleton />;
 };
 
 export default ItemsTree;

+ 17 - 0
packages/app/src/components/Sidebar/RecentChanges.module.scss

@@ -1,3 +1,5 @@
+@use '~/styles/mixins' as *;
+
 .grw-recent-changes-resize-button :global {
   font-size: 12px;
   line-height: normal;
@@ -15,6 +17,21 @@
 }
 
 .list-group-item :global {
+  .grw-recent-changes-skeleton-small {
+    @include grw-skeleton-text($font-size:14px, $line-height:16px);
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-h5 {
+    @include grw-skeleton-h5;
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-date {
+    @include grw-skeleton-text($font-size:10px, $line-height:12px);
+    width: 90px;
+  }
+
   .grw-recent-changes-item-lower {
     height: 17.5px;
   }

+ 43 - 67
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -16,6 +16,8 @@ import loggerFactory from '~/utils/logger';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
 import InfiniteScroll from './InfiniteScroll';
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import RecentChangesContentSkeleton from './Skeleton/RecentChangesContentSkeleton';
 
 import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import styles from './RecentChanges.module.scss';
@@ -23,11 +25,15 @@ import styles from './RecentChanges.module.scss';
 
 const logger = loggerFactory('growi:History');
 
-type PageItemProps = {
+type PageItemLowerProps = {
   page: IPageHasId,
 }
 
-const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
+type PageItemProps = PageItemLowerProps & {
+  isSmall: boolean
+}
+
+const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
     <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
       <div className="d-flex">
@@ -44,8 +50,7 @@ const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
 });
 PageItemLower.displayName = 'PageItemLower';
 
-
-const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
+const PageItem = memo(({ page, isSmall }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -75,67 +80,38 @@ const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
   });
 
   return (
-    <li className={`list-group-item ${styles['list-group-item']} py-3 px-0`}>
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
       <div className="d-flex w-100">
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">
           { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-2">
+          <h5 className={isSmall ? 'my-0 text-truncate' : 'my-2'}>
             <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
             {locked}
           </h5>
-          <div className="grw-tag-labels mt-1 mb-2">
+          {!isSmall && <div className="grw-tag-labels mt-1 mb-2">
             { tagElements }
-          </div>
+          </div>}
           <PageItemLower page={page} />
         </div>
       </div>
     </li>
   );
 });
-LargePageItem.displayName = 'LargePageItem';
-
-
-const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
-  const dPagePath = new DevidedPagePath(page.path, false, true);
-  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-  const FormerLink = () => (
-    <div className="grw-page-path-text-muted-container small">
-      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-    </div>
-  );
-
-  let locked;
-  if (page.grant !== 1) {
-    locked = <span><i className="icon-lock ml-2" /></span>;
-  }
-
-  return (
-    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
-      <div className="d-flex w-100">
-        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
-        <div className="flex-grow-1 ml-2">
-          { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-0 text-truncate">
-            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            {locked}
-          </h5>
-          <PageItemLower page={page} />
-        </div>
-      </div>
-    </li>
-  );
-});
-SmallPageItem.displayName = 'SmallPageItem';
+PageItem.displayName = 'PageItem';
 
 const RecentChanges = (): JSX.Element => {
+
   const PER_PAGE = 20;
   const { t } = useTranslation();
-  const swr = useSWRInifinitexRecentlyUpdated();
+  const swrInifinitexRecentlyUpdated = useSWRInifinitexRecentlyUpdated();
+  const { data: dataRecentlyUpdated, error, mutate: mutateRecentlyUpdated } = swrInifinitexRecentlyUpdated;
+
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-  const isEmpty = swr.data?.[0].length === 0;
-  const isReachingEnd = isEmpty || (swr.data && swr.data[swr.data.length - 1]?.length < PER_PAGE);
+  const isEmpty = dataRecentlyUpdated?.[0].length === 0;
+  const isLoading = error == null && dataRecentlyUpdated === undefined;
+  const isReachingEnd = isEmpty || (dataRecentlyUpdated && dataRecentlyUpdated[dataRecentlyUpdated.length - 1]?.length < PER_PAGE);
+
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
@@ -153,12 +129,10 @@ const RecentChanges = (): JSX.Element => {
   }, [retrieveSizePreferenceFromLocalStorage]);
 
   return (
-    <div data-testid="grw-recent-changes">
-      <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+    <div className="px-3" data-testid="grw-recent-changes">
+      <div className="grw-sidebar-content-header py-3 d-flex">
+        <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>
+        <SidebarHeaderReloadButton onClick={() => mutateRecentlyUpdated()}/>
         <div className="d-flex align-items-center">
           <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} custom-control custom-switch ml-1`}>
             <input
@@ -173,21 +147,23 @@ const RecentChanges = (): JSX.Element => {
           </div>
         </div>
       </div>
-      <div className="grw-recent-changes p-3">
-        <ul className="list-group list-group-flush">
-          <InfiniteScroll
-            swrInifiniteResponse={swr}
-            isReachingEnd={isReachingEnd}
-          >
-            {pages => pages.map(page => (
-              isRecentChangesSidebarSmall
-                ? <SmallPageItem key={page._id} page={page} />
-                : <LargePageItem key={page._id} page={page} />
-            ))
-            }
-          </InfiniteScroll>
-        </ul>
-      </div>
+      {
+        isLoading ? <RecentChangesContentSkeleton /> : (
+          <div className="grw-recent-changes py-3">
+            <ul className="list-group list-group-flush">
+              <InfiniteScroll
+                swrInifiniteResponse={swrInifinitexRecentlyUpdated}
+                isReachingEnd={isReachingEnd}
+              >
+                {pages => pages.map(
+                  page => <PageItem key={page._id} page={page} isSmall={isRecentChangesSidebarSmall} />,
+                )
+                }
+              </InfiniteScroll>
+            </ul>
+          </div>
+        )
+      }
     </div>
   );
 

+ 14 - 0
packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -0,0 +1,14 @@
+import React from 'react';
+
+type Props = {
+  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
+};
+
+export const SidebarHeaderReloadButton = ({ onClick }: Props) => {
+
+  return (
+    <button type="button" className="btn btn-sm ml-auto py-0 grw-btn-reload" onClick={onClick}>
+      <i className="icon icon-reload"></i>
+    </button>
+  );
+};

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../CustomSidebar.module.scss';
+
+const CustomSidebarContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text ${styles['grw-custom-sidebar-skeleton-text']}`} />
+    </div>
+  );
+};
+
+export default CustomSidebarContentSkeleton;

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../PageTree/ItemsTree.module.scss';
+
+const PageTreeContentSkeleton = (): JSX.Element => {
+
+  return (
+    <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} >
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+    </ul>
+  );
+};
+
+export default PageTreeContentSkeleton;

+ 40 - 0
packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../RecentChanges.module.scss';
+
+const SkeletonItem = () => {
+
+  const isSmall = window.localStorage.isRecentChangesSidebarSmall === 'true';
+
+  return (
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+      <div className="d-flex w-100">
+        <Skeleton additionalClass='rounded-circle picture' roundedPill />
+        <div className="flex-grow-1 ml-2">
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`} />
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`} />
+          <div className="d-flex justify-content-end grw-recent-changes-item-lower pt-1">
+            <Skeleton additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`} />
+          </div>
+        </div>
+      </div>
+    </li>
+  );
+};
+
+const RecentChangesContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className="grw-recent-changes py-3">
+      <ul className="list-group list-group-flush">
+        <SkeletonItem />
+        <SkeletonItem />
+        <SkeletonItem />
+        <li className={'list-group-item p-0'}></li>
+      </ul>
+    </div>);
+};
+
+export default RecentChangesContentSkeleton;

+ 6 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss

@@ -0,0 +1,6 @@
+@use '~/styles/mixins' as *;
+
+.grw-sidebar-content-header-skeleton {
+  @include grw-skeleton-h3;
+  max-width: 100%;
+}

+ 50 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+import CustomSidebarContentSkeleton from './CustomSidebarContentSkeleton';
+import PageTreeContentSkeleton from './PageTreeContentSkeleton';
+import RecentChangesContentSkeleton from './RecentChangesContentSkeleton';
+import TagContentSkeleton from './TagContentSkeleton';
+
+import styles from './SidebarSkeleton.module.scss';
+
+export const SidebarHeaderSkeleton = (): JSX.Element => {
+  return (
+    <div className="grw-sidebar-content-header py-3">
+      <Skeleton additionalClass={styles['grw-sidebar-content-header-skeleton']} />
+    </div>
+  );
+};
+
+export const SidebarSkeleton = (): JSX.Element => {
+
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let SidebarContentSkeleton: () => JSX.Element;
+  switch (currentSidebarContents) {
+
+    case SidebarContentsType.TAG:
+      SidebarContentSkeleton = TagContentSkeleton;
+      break;
+    case SidebarContentsType.RECENT:
+      SidebarContentSkeleton = RecentChangesContentSkeleton;
+      break;
+    case SidebarContentsType.CUSTOM:
+      SidebarContentSkeleton = CustomSidebarContentSkeleton;
+      break;
+    case SidebarContentsType.TREE:
+    default:
+      SidebarContentSkeleton = PageTreeContentSkeleton;
+      break;
+  }
+
+  return (
+    <div className={currentSidebarContents === SidebarContentsType.TAG ? 'px-4' : 'px-3'}>
+      <SidebarHeaderSkeleton />
+      <SidebarContentSkeleton />
+    </div>
+  );
+};

+ 23 - 0
packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../Tag.module.scss';
+
+export const TagListSkeleton = (): JSX.Element => {
+  return (
+    <Skeleton additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`} />
+  );
+};
+
+const TagContentSkeleton = (): JSX.Element => {
+
+  return (
+    <>
+      <Skeleton additionalClass={`${styles['grw-tag-skeleton-h3']} my-3`} />
+      <TagListSkeleton />
+    </>
+  );
+};
+
+export default TagContentSkeleton;

+ 10 - 0
packages/app/src/components/Sidebar/Tag.module.scss

@@ -0,0 +1,10 @@
+@use '~/styles/mixins' as *;
+
+.grw-tag-skeleton-h3 {
+  @include grw-skeleton-h3;
+  max-width: 120px;
+}
+
+.grw-tag-list-skeleton {
+  height: 90px;
+}

+ 5 - 10
packages/app/src/components/Sidebar/Tag.tsx

@@ -9,6 +9,9 @@ import { useSWRxTagsList } from '~/stores/tag';
 import TagCloudBox from '../TagCloudBox';
 import TagList from '../TagList';
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import { TagListSkeleton } from './Skeleton/TagContentSkeleton';
+
 
 const PAGING_LIMIT = 10;
 const TAG_CLOUD_LIMIT = 20;
@@ -44,22 +47,14 @@ const Tag: FC = () => {
     <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
-        <button
-          type="button"
-          className="btn btn-sm ml-auto grw-btn-reload"
-          onClick={onReload}
-        >
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => onReload()}/>
       </div>
 
       <h3 className="my-3">{t('tag_list')}</h3>
 
       { isLoading
         ? (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-          </div>
+          <TagListSkeleton />
         )
         : (
           <div data-testid="grw-tags-list">

+ 1 - 1
packages/app/src/components/Skeleton.tsx

@@ -12,7 +12,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill ?? ''}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill && 'rounded-pill'}`}></div>
     </div>
   );
 };

+ 4 - 1
packages/app/src/interfaces/customize.ts

@@ -1,7 +1,10 @@
+import { GrowiThemeMetadata } from '@growi/core';
+
 export type IResLayoutSetting = {
   isContainerFluid: boolean,
 };
 
 export type IResGrowiTheme = {
-  theme: string,
+  currentTheme: string,
+  pluginThemesMetadatas: GrowiThemeMetadata[],
 }

+ 8 - 2
packages/app/src/interfaces/plugin.ts

@@ -1,3 +1,5 @@
+import { GrowiThemeMetadata } from '@growi/core';
+
 export const GrowiPluginResourceType = {
   Template: 'template',
   Style: 'style',
@@ -12,11 +14,11 @@ export type GrowiPluginOrigin = {
   ghTag?: string,
 }
 
-export type GrowiPlugin = {
+export type GrowiPlugin<M extends GrowiPluginMeta = GrowiPluginMeta> = {
   isEnabled: boolean,
   installedPath: string,
   origin: GrowiPluginOrigin,
-  meta: GrowiPluginMeta,
+  meta: M,
 }
 
 export type GrowiPluginMeta = {
@@ -25,3 +27,7 @@ export type GrowiPluginMeta = {
   desc?: string,
   author?: string,
 }
+
+export type GrowiThemePluginMeta = GrowiPluginMeta & {
+  themes: GrowiThemeMetadata[]
+}

+ 0 - 19
packages/app/src/interfaces/theme.ts

@@ -1,22 +1,3 @@
-export const GrowiThemes = {
-  DEFAULT: 'default',
-  ANTARCTIC: 'antarctic',
-  BLACKBOARD: 'blackboard',
-  CHRISTMAS: 'christmas',
-  FIRE_RED: 'fire-red',
-  FUTURE: 'future',
-  HALLOWEEN: 'halloween',
-  HUFFLEPUFF: 'hufflepuff',
-  ISLAND: 'island',
-  JADE_GREEN: 'jade-green',
-  KIBELA: 'kibela',
-  MONO_BLUE: 'mono-blue',
-  NATURE: 'nature',
-  SPRING: 'spring',
-  WOOD: 'wood',
-} as const;
-export type GrowiThemes = typeof GrowiThemes[keyof typeof GrowiThemes];
-
 export const PrismThemes = {
   OneLight: 'one-light',
 } as const;

+ 56 - 54
packages/app/src/pages/_document.page.tsx

@@ -1,85 +1,82 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
-import type { PresetThemesManifest } from '@growi/preset-themes';
-import { getManifestKeyFromTheme } from '@growi/preset-themes';
-import mongoose from 'mongoose';
+import type { ViteManifest } from '@growi/core';
+import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import Document, {
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
 } from 'next/document';
 
-import { ActivatePluginService, GrowiPluginManifestEntries } from '~/client/services/activate-plugin';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { GrowiPlugin, GrowiPluginResourceType } from '~/interfaces/plugin';
-import type { GrowiThemes } from '~/interfaces/theme';
+import type { IPluginService, GrowiPluginResourceEntries } from '~/server/service/plugin';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:page:_document');
 
-type HeadersForPresetThemesProps = {
-  theme: GrowiThemes,
-  manifest: PresetThemesManifest,
+type HeadersForThemesProps = {
+  theme: string,
+  presetThemesManifest: ViteManifest,
+  pluginThemeHref: string | undefined,
 }
-const HeadersForPresetThemes = (props: HeadersForPresetThemesProps): JSX.Element => {
-  const { theme, manifest } = props;
-
-  let themeKey = getManifestKeyFromTheme(theme);
-  if (!(themeKey in manifest)) {
-    logger.warn(`The key for '${theme} does not exist in preset-themes manifest`);
-    themeKey = getManifestKeyFromTheme('default');
-  }
-  const href = `/static/preset-themes/${manifest[themeKey].file}`; // configured by express.static
+const HeadersForThemes = (props: HeadersForThemesProps): JSX.Element => {
+  const {
+    theme, presetThemesManifest, pluginThemeHref,
+  } = props;
 
   const elements: JSX.Element[] = [];
 
-  elements.push(
-    <link rel="stylesheet" key={`link_preset-themes-${theme}`} href={href} />,
-  );
+  // when plugin theme is specified
+  if (pluginThemeHref != null) {
+    elements.push(
+      <link rel="stylesheet" key={`link_custom-themes-${theme}`} href={pluginThemeHref} />,
+    );
+  }
+  // preset theme
+  else {
+    const themeMetadata = PresetThemesMetadatas.find(p => p.name === theme);
+    const manifestKey = themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
+    if (themeMetadata == null || !(themeMetadata.manifestKey in presetThemesManifest)) {
+      logger.warn(`Use default theme because the key for '${theme} does not exist in preset-themes manifest`);
+    }
+    const href = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
+    elements.push(
+      <link rel="stylesheet" key={`link_preset-themes-${theme}`} href={href} />,
+    );
+  }
 
   return <>{elements}</>;
 };
 
 type HeadersForGrowiPluginProps = {
-  pluginManifestEntries: GrowiPluginManifestEntries;
+  pluginResourceEntries: GrowiPluginResourceEntries;
 }
 const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element => {
-  const { pluginManifestEntries } = props;
+  const { pluginResourceEntries } = props;
 
   return (
     <>
-      { pluginManifestEntries.map(([growiPlugin, manifest]) => {
-        const { types } = growiPlugin.meta;
-
-        const elements: JSX.Element[] = [];
-
-        // add script
-        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
-          elements.push(<>
-            {/* eslint-disable-next-line @next/next/no-sync-scripts */ }
-            <script type="module" key={`script_${growiPlugin.installedPath}`}
-              src={`/static/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`} />
-          </>);
+      { pluginResourceEntries.map(([installedPath, href]) => {
+        if (href.endsWith('.js')) {
+          // eslint-disable-next-line @next/next/no-sync-scripts
+          return <script type="module" key={`script_${installedPath}`} src={href} />;
         }
-        // add link
-        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
-          elements.push(<>
-            <link rel="stylesheet" key={`link_${growiPlugin.installedPath}`}
-              href={`/static/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`} />
-          </>);
+        if (href.endsWith('.css')) {
+          // eslint-disable-next-line @next/next/no-sync-scripts
+          return <link rel="stylesheet" key={`link_${installedPath}`} href={href} />;
         }
-
-        return elements;
+        return <></>;
       }) }
     </>
   );
 };
 
 interface GrowiDocumentProps {
-  theme: GrowiThemes,
+  theme: string,
   customCss: string;
-  presetThemesManifest: PresetThemesManifest,
-  pluginManifestEntries: GrowiPluginManifestEntries;
+  presetThemesManifest: ViteManifest,
+  pluginThemeHref: string | undefined,
+  pluginResourceEntries: GrowiPluginResourceEntries;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
@@ -88,7 +85,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { configManager, customizeService } = crowi;
+    const { configManager, customizeService, pluginService } = crowi;
 
     const theme = configManager.getConfig('crowi', 'customize:theme');
     const customCss: string = customizeService.getCustomCss();
@@ -97,18 +94,22 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const presetThemesManifest = await import('@growi/preset-themes/dist/themes/manifest.json').then(imported => imported.default);
 
     // retrieve plugin manifests
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
-    const growiPlugins = await GrowiPlugin.find({ isEnabled: true });
-    const pluginManifestEntries: GrowiPluginManifestEntries = await ActivatePluginService.retrievePluginManifests(growiPlugins);
+    const pluginResourceEntries = await (pluginService as IPluginService).retrieveAllPluginResourceEntries();
+    const pluginThemeHref = await (pluginService as IPluginService).retrieveThemeHref(theme);
 
     return {
-      ...initialProps, theme, customCss, presetThemesManifest, pluginManifestEntries,
+      ...initialProps,
+      theme,
+      customCss,
+      presetThemesManifest,
+      pluginThemeHref,
+      pluginResourceEntries,
     };
   }
 
   override render(): JSX.Element {
     const {
-      customCss, theme, presetThemesManifest, pluginManifestEntries,
+      customCss, theme, presetThemesManifest, pluginThemeHref, pluginResourceEntries,
     } = this.props;
 
     return (
@@ -127,8 +128,9 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
-          <HeadersForPresetThemes theme={theme} manifest={presetThemesManifest} />
-          <HeadersForGrowiPlugin pluginManifestEntries={pluginManifestEntries} />
+          <HeadersForThemes theme={theme}
+            presetThemesManifest={presetThemesManifest} pluginThemeHref={pluginThemeHref} />
+          <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
         </Head>
         <body>
           <Main />

+ 1 - 1
packages/app/src/pages/admin/customize.page.tsx

@@ -14,7 +14,7 @@ import { useCustomizeTitle, useCurrentUser } from '~/stores/context';
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
-const CustomizeSettingContents = dynamic(() => import('~/components/Admin//Customize/Customize'), { ssr: false });
+const CustomizeSettingContents = dynamic(() => import('~/components/Admin/Customize/Customize'), { ssr: false });
 
 
 type Props = CommonProps & {

+ 1 - 1
packages/app/src/pages/admin/plugins.page.tsx

@@ -27,7 +27,7 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
 
-  const title = 'Plugins Extension';
+  const title = 'Plugins';
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {

+ 2 - 3
packages/app/src/server/models/config.ts

@@ -1,8 +1,7 @@
+import { PresetThemes } from '@growi/preset-themes';
 import { Types, Schema } from 'mongoose';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { GrowiThemes } from '~/interfaces/theme';
-
 import { RehypeSanitizeOption } from '../../interfaces/rehype';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
@@ -126,7 +125,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:title' : undefined,
   'customize:highlightJsStyle' : 'github',
   'customize:highlightJsStyleBorder' : false,
-  'customize:theme' : GrowiThemes.DEFAULT,
+  'customize:theme' : PresetThemes.DEFAULT,
   'customize:isContainerFluid' : false,
   'customize:isEnabledTimeline' : true,
   'customize:isEnabledAttachTitleHeader' : false,

+ 31 - 3
packages/app/src/server/models/growi-plugin.ts

@@ -1,18 +1,36 @@
+import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import {
   Schema, Model, Document,
 } from 'mongoose';
 
 import {
-  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType,
+  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
 } from '~/interfaces/plugin';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 export interface GrowiPluginDocument extends GrowiPlugin, Document {
 }
-export type GrowiPluginModel = Model<GrowiPluginDocument>
+export interface GrowiPluginModel extends Model<GrowiPluginDocument> {
+  findEnabledPlugins(): Promise<GrowiPlugin[]>
+  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<GrowiPlugin[]>
+}
+
+const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
+  name: { type: String, required: true },
+  manifestKey: { type: String, required: true },
+  schemeType: {
+    type: String,
+    enum: GrowiThemeSchemeType,
+    require: true,
+  },
+  bg: { type: String, required: true },
+  topbar: { type: String, required: true },
+  sidebar: { type: String, required: true },
+  accent: { type: String, required: true },
+});
 
-const growiPluginMetaSchema = new Schema<GrowiPluginMeta>({
+const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
   name: { type: String, required: true },
   types: {
     type: [String],
@@ -21,6 +39,7 @@ const growiPluginMetaSchema = new Schema<GrowiPluginMeta>({
   },
   desc: { type: String },
   author: { type: String },
+  themes: [growiThemeMetadataSchema],
 });
 
 const growiPluginOriginSchema = new Schema<GrowiPluginOrigin>({
@@ -36,5 +55,14 @@ const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
   meta: growiPluginMetaSchema,
 });
 
+growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<GrowiPlugin[]> {
+  return this.find({ isEnabled: true });
+};
+growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<GrowiPlugin[]> {
+  return this.find({
+    isEnabled: true,
+    'meta.types': { $in: types },
+  });
+};
 
 export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);

+ 13 - 2
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -1,8 +1,10 @@
 /* eslint-disable no-unused-vars */
 
 import { ErrorV3 } from '@growi/core';
+import mongoose from 'mongoose';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { GrowiPluginResourceType } from '~/interfaces/plugin';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
@@ -271,8 +273,17 @@ module.exports = (crowi) => {
   router.get('/theme', loginRequiredStrictly, adminRequired, async(req, res) => {
 
     try {
-      const theme = await crowi.configManager.getConfig('crowi', 'customize:theme');
-      return res.apiv3({ theme });
+      const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');
+
+      // retrieve plugin manifests
+      const GrowiPluginModel = mongoose.model('GrowiPlugin');
+      const themePlugins = await GrowiPluginModel.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
+
+      const pluginThemesMetadatas = themePlugins
+        .map(themePlugin => themePlugin.meta.themes)
+        .flat();
+
+      return res.apiv3({ currentTheme, pluginThemesMetadatas });
     }
     catch (err) {
       const msg = 'Error occurred in getting theme';

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

@@ -197,6 +197,7 @@ module.exports = function(crowi, app) {
     .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.renderUserActivationPage(crowi))
     .use(userActivation.tokenErrorHandlerMiddeware(crowi)));
 
+  app.get('/share$', (req, res) => res.redirect('/'));
   app.get('/share/:linkId', next.delegateToNext);
 
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));

+ 112 - 7
packages/app/src/server/service/plugin.ts

@@ -1,16 +1,21 @@
-import fs from 'fs';
+import fs, { readFileSync } from 'fs';
 import path from 'path';
 
+import { GrowiThemeMetadata, ViteManifest } from '@growi/core';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
-import type { GrowiPlugin, GrowiPluginOrigin } from '~/interfaces/plugin';
+import {
+  GrowiPlugin, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
+} from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
+import type { GrowiPluginModel } from '../models/growi-plugin';
+
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
@@ -18,8 +23,24 @@ const pluginStoringPath = resolveFromRoot('tmp/plugins');
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 
+const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
+
+export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-export class PluginService {
+
+function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
+  const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
+  const manifestStr: string = readFileSync(manifestPath, 'utf-8');
+  return JSON.parse(manifestStr);
+}
+
+export interface IPluginService {
+  install(origin: GrowiPluginOrigin): Promise<void>
+  retrieveThemeHref(theme: string): Promise<string | undefined>
+  retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
+}
+
+export class PluginService implements IPluginService {
 
   async install(origin: GrowiPluginOrigin): Promise<void> {
     // download
@@ -48,7 +69,7 @@ export class PluginService {
     return;
   }
 
-  async download(requestUrl: string, ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
+  private async download(requestUrl: string, ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
 
     const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
     const unzippedPath = path.join(pluginStoringPath, ghOrganizationName);
@@ -109,15 +130,15 @@ export class PluginService {
     return;
   }
 
-  async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
+  private async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
     const GrowiPlugin = mongoose.model('GrowiPlugin');
     await GrowiPlugin.insertMany(plugins);
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+  private static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
     const packageJsonPath = path.resolve(pluginStoringPath, installedPath, 'package.json');
-    const packageJson = await import(packageJsonPath);
+    const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
 
     const { growiPlugin } = packageJson;
     const {
@@ -155,6 +176,14 @@ export class PluginService {
       },
     };
 
+    // add theme metadata
+    if (growiPlugin.types.includes(GrowiPluginResourceType.Theme)) {
+      (plugin as GrowiPlugin<GrowiThemePluginMeta>).meta = {
+        ...plugin.meta,
+        themes: growiPlugin.themes,
+      };
+    }
+
     logger.info('Plugin detected => ', plugin);
 
     return [plugin];
@@ -164,4 +193,80 @@ export class PluginService {
     return [];
   }
 
+
+  async retrieveThemeHref(theme: string): Promise<string | undefined> {
+
+    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
+
+    let matchedPlugin: GrowiPlugin | undefined;
+    let matchedThemeMetadata: GrowiThemeMetadata | undefined;
+
+    try {
+      // retrieve plugin manifests
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as GrowiPlugin<GrowiThemePluginMeta>[];
+
+      growiPlugins
+        .forEach(async(growiPlugin) => {
+          const themeMetadatas = growiPlugin.meta.themes;
+          const themeMetadata = themeMetadatas.find(t => t.name === theme);
+
+          // found
+          if (themeMetadata != null) {
+            matchedPlugin = growiPlugin;
+            matchedThemeMetadata = themeMetadata;
+          }
+        });
+    }
+    catch (e) {
+      logger.error(`Could not find the theme '${theme}' from GrowiPlugin documents.`, e);
+    }
+
+    try {
+      if (matchedPlugin != null && matchedThemeMetadata != null) {
+        const manifest = await retrievePluginManifest(matchedPlugin);
+        return `${PLUGINS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
+      }
+    }
+    catch (e) {
+      logger.error(`Could not read manifest file for the theme '${theme}'`, e);
+    }
+  }
+
+  async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
+
+    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
+
+    const entries: GrowiPluginResourceEntries = [];
+
+    try {
+      const growiPlugins = await GrowiPlugin.findEnabledPlugins();
+
+      growiPlugins.forEach(async(growiPlugin) => {
+        try {
+          const { types } = growiPlugin.meta;
+          const manifest = await retrievePluginManifest(growiPlugin);
+
+          // add script
+          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
+            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`;
+            entries.push([growiPlugin.installedPath, href]);
+          }
+          // add link
+          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
+            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
+            entries.push([growiPlugin.installedPath, href]);
+          }
+        }
+        catch (e) {
+          logger.warn(e);
+        }
+      });
+    }
+    catch (e) {
+      logger.error('Could not retrieve GrowiPlugin documents.', e);
+    }
+
+    return entries;
+  }
+
 }

+ 3 - 2
packages/app/src/server/views/widget/page_alerts.html

@@ -28,7 +28,8 @@
       {% endif %}
     {% endif %}
 
-    {% if redirectFrom or req.query.redirectFrom %}
+    <!-- This code has been replaced to <PageRedirectedAlert /> -->
+    <!-- {% if redirectFrom or req.query.redirectFrom %}
     <div class="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
       <span>
         {% set fromPath = req.query.redirectFrom %}
@@ -44,7 +45,7 @@
         </button>
       {% endif %}
     </div>
-    {% endif %}
+    {% endif %} -->
 
     {% if req.query.unlinked %}
     <div class="alert alert-info d-edit-none py-3 px-4">

+ 38 - 6
packages/app/src/services/renderer/renderer.tsx

@@ -335,6 +335,10 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     slug,
@@ -344,6 +348,7 @@ export const generateViewOptions = (
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
     [toc.rehypePluginStore, { storeTocNode }],
     // [autoLinkHeadings, {
@@ -373,7 +378,9 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
-  verifySanitizePlugin(options, false);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
   return options;
 };
 
@@ -386,16 +393,23 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
+
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [toc.rehypePluginRestore, { tocNode }],
-    [sanitize, commonSanitizeOption],
+    rehypeSanitizePlugin,
   );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   // }]);
 
-  verifySanitizePlugin(options);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options);
+  }
   return options;
 };
 
@@ -417,6 +431,10 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
@@ -426,6 +444,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
   );
 
@@ -436,7 +455,9 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     components.table = Table;
   }
 
-  verifySanitizePlugin(options, false);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
   return options;
 };
 
@@ -458,6 +479,10 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption, addLineNumberAttribute.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
@@ -468,6 +493,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
   );
 
@@ -493,12 +519,18 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
-    [sanitize, commonSanitizeOption],
+    rehypeSanitizePlugin,
   );
 
-  verifySanitizePlugin(options);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options);
+  }
   return options;
 };
 

+ 12 - 6
packages/app/src/stores/admin/customize.tsx

@@ -1,6 +1,6 @@
 import { useCallback } from 'react';
 
-import { SWRResponse } from 'swr';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
@@ -27,22 +27,28 @@ export const useSWRxLayoutSetting = (): SWRResponse<IResLayoutSetting, Error> &
   };
 };
 
-export const useSWRxGrowiTheme = (): SWRResponse<string, Error> => {
+export const useSWRxGrowiThemeSetting = (): SWRResponse<IResGrowiTheme, Error> => {
 
   const fetcher = useCallback(async() => {
     const res = await apiv3Get<IResGrowiTheme>('/customize-setting/theme');
-    return res.data.theme;
+    return res.data;
   }, []);
 
-  const swrResponse = useSWRImmutable('/customize-setting/theme', fetcher);
+  const swrResponse = useSWR('/customize-setting/theme', fetcher);
 
   const update = async(theme: string) => {
     await apiv3Put('/customize-setting/layout', { theme });
-    await swrResponse.mutate();
+
+    if (swrResponse.data == null) {
+      swrResponse.mutate();
+      return;
+    }
+
+    const newData = { ...swrResponse.data, currentTheme: theme };
     // The updateFn should be a promise or asynchronous function to handle the remote mutation
     // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
     // Moreover, `async() => false` does not work since it's too fast to be calculated.
-    await swrResponse.mutate(new Promise(r => setTimeout(() => r(theme), 10)), { optimisticData: () => theme });
+    await swrResponse.mutate(new Promise(r => setTimeout(() => r(newData), 10)), { optimisticData: () => newData });
   };
 
   return Object.assign(

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

@@ -5,7 +5,6 @@ import useSWRImmutable from 'swr/immutable';
 import { SupportedActionType } from '~/interfaces/activity';
 import { EditorConfig } from '~/interfaces/editor-settings';
 import { RendererConfig } from '~/interfaces/services/renderer';
-import { GrowiThemes } from '~/interfaces/theme';
 import InterceptorManager from '~/services/interceptor-manager';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';

+ 21 - 0
packages/app/src/styles/_mixins.scss

@@ -160,3 +160,24 @@
     content: $code;
   }
 }
+
+@mixin grw-skeleton-text($font-size, $line-height) {
+  height: $line-height;
+  padding: (($line-height - $font-size)  / 2) 0;
+}
+/*
+.example {
+  @include grw-skeleton-text($font-size:$size, $line-height:$height);
+  max-width: 100%;
+}
+*/
+
+// values from './bootstrap/override'
+
+@mixin grw-skeleton-h3 {
+  @include grw-skeleton-text(21px, 30px);
+}
+
+@mixin grw-skeleton-h5 {
+  @include grw-skeleton-text(16px, 18px);
+}

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

@@ -16,6 +16,7 @@ export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
 export * from './interfaces/growi-facade';
+export * from './interfaces/growi-theme-metadata';
 export * from './interfaces/has-object-id';
 export * from './interfaces/lang';
 export * from './interfaces/page';
@@ -24,6 +25,7 @@ export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/template';
 export * from './interfaces/user';
+export * from './interfaces/vite';
 export * from './models/devided-page-path';
 export * from './models/vo/error-apiv3';
 export * from './service/localstorage-manager';

+ 17 - 0
packages/core/src/interfaces/growi-theme-metadata.ts

@@ -0,0 +1,17 @@
+export const GrowiThemeSchemeType = {
+  BOTH: 'both',
+  LIGHT: 'light',
+  DARK: 'dark',
+} as const;
+export type GrowiThemeSchemeType = typeof GrowiThemeSchemeType[keyof typeof GrowiThemeSchemeType];
+
+export type GrowiThemeMetadata = {
+  name: string,
+  manifestKey: string,
+  schemeType: GrowiThemeSchemeType,
+  bg: string,
+  topbar: string,
+  sidebar: string,
+  accent: string,
+  isPresetTheme?: boolean,
+};

+ 10 - 0
packages/core/src/interfaces/vite.ts

@@ -0,0 +1,10 @@
+export type ViteManifestValue = {
+  file: string,
+  src: string,
+  isEntry?: boolean,
+  css?: string[],
+}
+
+export type ViteManifest = {
+  [key: string]: ViteManifestValue,
+}

+ 0 - 1
packages/preset-themes/.eslintrc.js

@@ -1,6 +1,5 @@
 module.exports = {
   extends: [
-    'weseek/react',
     'weseek/typescript',
   ],
   env: {

+ 82 - 0
packages/preset-themes/src/consts/preset-themes.ts

@@ -0,0 +1,82 @@
+import { GrowiThemeMetadata, GrowiThemeSchemeType } from '../interfaces/growi-theme-metadata';
+
+const { BOTH, LIGHT, DARK } = GrowiThemeSchemeType;
+
+export const PresetThemes = {
+  DEFAULT: 'default',
+  ANTARCTIC: 'antarctic',
+  BLACKBOARD: 'blackboard',
+  CHRISTMAS: 'christmas',
+  FIRE_RED: 'fire-red',
+  FUTURE: 'future',
+  HALLOWEEN: 'halloween',
+  HUFFLEPUFF: 'hufflepuff',
+  ISLAND: 'island',
+  JADE_GREEN: 'jade-green',
+  KIBELA: 'kibela',
+  MONO_BLUE: 'mono-blue',
+  NATURE: 'nature',
+  SPRING: 'spring',
+  WOOD: 'wood',
+} as const;
+export type PresetThemes = typeof PresetThemes[keyof typeof PresetThemes];
+
+/* eslint-disable no-multi-spaces, */
+
+export const DefaultThemeMetadata: GrowiThemeMetadata = {
+  name: PresetThemes.DEFAULT,
+  manifestKey: `src/styles/${PresetThemes.DEFAULT}.scss`,
+  schemeType: BOTH,
+  bg: '#ffffff',
+  topbar: '#2a2929',
+  sidebar: '#122c55',
+  accent: '#209fd8',
+  isPresetTheme: true,
+};
+
+export const PresetThemesMetadatas: GrowiThemeMetadata[] = [
+  // support both of light and dark
+  DefaultThemeMetadata,
+  {
+    name: PresetThemes.MONO_BLUE,     schemeType: BOTH, bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', accent: '#00587A',
+  }, {
+    name: PresetThemes.HUFFLEPUFF,    schemeType: BOTH, bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', accent: '#993439',
+  }, {
+    name: PresetThemes.FIRE_RED,      schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#EA5532',
+  }, {
+    name: PresetThemes.JADE_GREEN,    schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#38B48B',
+  },
+  // light only
+  {
+    name: PresetThemes.NATURE,        schemeType: LIGHT, bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', accent: '#460039',
+  }, {
+    name: PresetThemes.WOOD,          schemeType: LIGHT, bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', accent: '#aaa45f',
+  }, {
+    name: PresetThemes.ISLAND,        schemeType: LIGHT, bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', accent: 'rgba(183, 226, 219, 1)',
+  }, {
+    name: PresetThemes.CHRISTMAS,     schemeType: LIGHT, bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', accent: '#d3c665',
+  }, {
+    name: PresetThemes.ANTARCTIC,     schemeType: LIGHT, bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', accent: '#fa9913',
+  }, {
+    name: PresetThemes.SPRING,        schemeType: LIGHT, bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', accent: '#67a856',
+  }, {
+    name: PresetThemes.KIBELA,        schemeType: LIGHT, bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', accent: '#b5cbf79c',
+  },
+  // dark only
+  {
+    name: PresetThemes.FUTURE,        schemeType: DARK, bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', accent: '#00b5b7',
+  }, {
+    name: PresetThemes.HALLOWEEN,     schemeType: DARK, bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', accent: '#e9af2b',
+  }, {
+    name: PresetThemes.BLACKBOARD,    schemeType: DARK, bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', accent: '#DA8506',
+  },
+]
+  // fill in missing information
+  .map((metadata) => {
+    return {
+      ...metadata,
+      isPresetTheme: true,
+      manifestKey: `src/styles/${metadata.name}.scss`,
+    };
+  });
+/* eslint-disable no-multi-spaces */

+ 3 - 2
packages/preset-themes/src/index.ts

@@ -1,2 +1,3 @@
-export * from './interfaces/manifest';
-export * from './utils';
+export * from './consts/preset-themes';
+
+export const manifestPath = 'dist/themes/manifest.json';

+ 17 - 0
packages/preset-themes/src/interfaces/growi-theme-metadata.ts

@@ -0,0 +1,17 @@
+export const GrowiThemeSchemeType = {
+  BOTH: 'both',
+  LIGHT: 'light',
+  DARK: 'dark',
+} as const;
+export type GrowiThemeSchemeType = typeof GrowiThemeSchemeType[keyof typeof GrowiThemeSchemeType];
+
+export type GrowiThemeMetadata = {
+  name: string,
+  manifestKey: string,
+  schemeType: GrowiThemeSchemeType,
+  bg: string,
+  topbar: string,
+  sidebar: string,
+  accent: string,
+  isPresetTheme?: boolean,
+};

+ 0 - 7
packages/preset-themes/src/interfaces/manifest.ts

@@ -1,7 +0,0 @@
-export type PresetThemesManifest = {
-  [key: string]: {
-    file: string,
-    src: string,
-    isEntry?: boolean,
-  }
-}

+ 0 - 5
packages/preset-themes/src/utils/index.ts

@@ -1,5 +0,0 @@
-export const manifestPath = 'dist/themes/manifest.json';
-
-export const getManifestKeyFromTheme = (theme: string): string => {
-  return `src/styles/${theme}.scss`;
-};

+ 5 - 0
yarn.lock

@@ -18479,6 +18479,11 @@ react-datepicker@^4.7.0:
     react-onclickoutside "^6.12.0"
     react-popper "^2.2.5"
 
+react-disable@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/react-disable/-/react-disable-0.1.1.tgz#86d2d0932259f626a70fa46f63d6d61cbe7dd066"
+  integrity sha512-KKEDYJUnF8hIPlmGYJu38HG8BlBB4EElCFY1zfA9W46/MF76DSGvgcduWl1eVT/CAw3ahb2sWTSfhon+kPSiKw==
+
 react-dnd-html5-backend@^14.1.0:
   version "14.1.0"
   resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz#b35a3a0c16dd3a2bfb5eb7ec62cf0c2cace8b62f"