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

Merge branch 'master' into imprv/implement-skeltons-of-sidebar

kymn 3 лет назад
Родитель
Сommit
79b39a86ef
34 измененных файлов с 532 добавлено и 306 удалено
  1. 2 19
      packages/app/src/client/services/activate-plugin.ts
  2. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  3. 0 103
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  4. 67 0
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.tsx
  5. 22 10
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  6. 0 47
      packages/app/src/components/Admin/Customize/ThemeColorBox.jsx
  7. 42 0
      packages/app/src/components/Admin/Customize/ThemeColorBox.tsx
  8. 2 2
      packages/app/src/components/Layout/Admin.module.scss
  9. 6 2
      packages/app/src/components/Page.tsx
  10. 1 1
      packages/app/src/components/PageAlert/PageRedirectedAlert.tsx
  11. 14 4
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  12. 4 1
      packages/app/src/interfaces/customize.ts
  13. 8 2
      packages/app/src/interfaces/plugin.ts
  14. 0 19
      packages/app/src/interfaces/theme.ts
  15. 56 54
      packages/app/src/pages/_document.page.tsx
  16. 1 1
      packages/app/src/pages/admin/customize.page.tsx
  17. 1 1
      packages/app/src/pages/admin/plugins.page.tsx
  18. 2 3
      packages/app/src/server/models/config.ts
  19. 31 3
      packages/app/src/server/models/growi-plugin.ts
  20. 13 2
      packages/app/src/server/routes/apiv3/customize-setting.js
  21. 1 0
      packages/app/src/server/routes/index.js
  22. 112 7
      packages/app/src/server/service/plugin.ts
  23. 3 2
      packages/app/src/server/views/widget/page_alerts.html
  24. 12 6
      packages/app/src/stores/admin/customize.tsx
  25. 0 1
      packages/app/src/stores/context.tsx
  26. 2 0
      packages/core/src/index.ts
  27. 17 0
      packages/core/src/interfaces/growi-theme-metadata.ts
  28. 10 0
      packages/core/src/interfaces/vite.ts
  29. 0 1
      packages/preset-themes/.eslintrc.js
  30. 82 0
      packages/preset-themes/src/consts/preset-themes.ts
  31. 3 2
      packages/preset-themes/src/index.ts
  32. 17 0
      packages/preset-themes/src/interfaces/growi-theme-metadata.ts
  33. 0 7
      packages/preset-themes/src/interfaces/manifest.ts
  34. 0 5
      packages/preset-themes/src/utils/index.ts

+ 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();
 

+ 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;
       }
     }

+ 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/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) {

+ 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">

+ 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';

+ 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`;
-};