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

Merge pull request #6291 from weseek/feat/load-theme-styles

feat: Load theme styles
Yuki Takei 3 лет назад
Родитель
Сommit
89ef78bb16
38 измененных файлов с 234 добавлено и 179 удалено
  1. 1 0
      packages/app/package.json
  2. 0 4
      packages/app/src/client/boot.js
  3. 0 73
      packages/app/src/client/util/color-scheme.js
  4. 5 6
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  5. 4 5
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  6. 17 16
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  7. 14 3
      packages/app/src/components/Layout/RawLayout.tsx
  8. 12 28
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  9. 4 3
      packages/app/src/components/PageEditor/EmojiPicker.tsx
  10. 7 7
      packages/app/src/components/Theme/ThemeAntarctic.module.scss
  11. 8 0
      packages/app/src/components/Theme/ThemeAntarctic.tsx
  12. 7 7
      packages/app/src/components/Theme/ThemeBlackboard.module.scss
  13. 8 0
      packages/app/src/components/Theme/ThemeBlackboard.tsx
  14. 7 7
      packages/app/src/components/Theme/ThemeChristmas.module.scss
  15. 8 0
      packages/app/src/components/Theme/ThemeChristmas.tsx
  16. 9 9
      packages/app/src/components/Theme/ThemeDefault.module.scss
  17. 8 0
      packages/app/src/components/Theme/ThemeDefault.tsx
  18. 0 0
      packages/app/src/components/Theme/ThemeFireRed.module.scss
  19. 0 0
      packages/app/src/components/Theme/ThemeFuture.module.scss
  20. 0 0
      packages/app/src/components/Theme/ThemeHalloween.module.scss
  21. 0 0
      packages/app/src/components/Theme/ThemeHufflepuff.module.scss
  22. 0 0
      packages/app/src/components/Theme/ThemeIsland.module.scss
  23. 0 0
      packages/app/src/components/Theme/ThemeJadeGreen.module.scss
  24. 0 0
      packages/app/src/components/Theme/ThemeKibela.module.scss
  25. 0 0
      packages/app/src/components/Theme/ThemeMonoBlue.module.scss
  26. 0 0
      packages/app/src/components/Theme/ThemeNature.module.scss
  27. 0 0
      packages/app/src/components/Theme/ThemeSpring.module.scss
  28. 0 0
      packages/app/src/components/Theme/ThemeWood.module.scss
  29. 12 0
      packages/app/src/components/Theme/utils/ThemeInjector.tsx
  30. 31 0
      packages/app/src/components/Theme/utils/ThemeProvider.tsx
  31. 18 0
      packages/app/src/interfaces/theme.ts
  32. 1 4
      packages/app/src/pages/[[...path]].page.tsx
  33. 14 5
      packages/app/src/pages/_app.page.tsx
  34. 4 1
      packages/app/src/pages/commons.ts
  35. 3 1
      packages/app/src/server/models/config.ts
  36. 5 0
      packages/app/src/stores/context.tsx
  37. 22 0
      packages/app/src/stores/use-next-themes.ts
  38. 5 0
      yarn.lock

+ 1 - 0
packages/app/package.json

@@ -126,6 +126,7 @@
     "multer-autoreap": "^1.0.3",
     "next": "^12.1.6",
     "next-i18next": "^11.0.0",
+    "next-themes": "^0.2.0",
     "next-transpile-modules": "^9.0.0",
     "nocache": "^3.0.1",
     "nodemailer": "^6.6.2",

+ 0 - 4
packages/app/src/client/boot.js

@@ -1,9 +1,5 @@
-import {
-  applyColorScheme,
-} from './util/color-scheme';
 import {
   applyOldIos,
 } from './util/old-ios';
 
-applyColorScheme();
 applyOldIos();

+ 0 - 73
packages/app/src/client/util/color-scheme.js

@@ -1,73 +0,0 @@
-const mediaQueryListForDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
-
-function isUserPreferenceExists() {
-  return localStorage.preferDarkModeByUser != null;
-}
-
-function isPreferedDarkModeByUser() {
-  return localStorage.preferDarkModeByUser === 'true';
-}
-
-function isDarkMode() {
-  if (isUserPreferenceExists()) {
-    return isPreferedDarkModeByUser();
-  }
-  return mediaQueryListForDarkMode.matches;
-}
-
-/**
- * Apply color scheme as 'dark' attribute of <html></html>
- */
-function applyColorScheme() {
-  let isDarkMode = mediaQueryListForDarkMode.matches;
-  if (isUserPreferenceExists()) {
-    isDarkMode = isPreferedDarkModeByUser();
-  }
-
-  // switch to dark mode
-  if (isDarkMode) {
-    document.documentElement.removeAttribute('light');
-    document.documentElement.setAttribute('dark', 'true');
-  }
-  // switch to light mode
-  else {
-    document.documentElement.setAttribute('light', 'true');
-    document.documentElement.removeAttribute('dark');
-  }
-}
-
-/**
- * Remove color scheme preference
- */
-function removeUserPreference() {
-  if (isUserPreferenceExists()) {
-    delete localStorage.removeItem('preferDarkModeByUser');
-  }
-}
-
-/**
- * Set color scheme preference
- * @param {boolean} isDarkMode
- */
-function updateUserPreference(isDarkMode) {
-  // store settings to localStorage
-  localStorage.preferDarkModeByUser = isDarkMode;
-}
-
-/**
- * Set color scheme preference with OS settings
- */
-function updateUserPreferenceWithOsSettings() {
-  localStorage.preferDarkModeByUser = mediaQueryListForDarkMode.matches;
-}
-
-export {
-  mediaQueryListForDarkMode,
-  isUserPreferenceExists,
-  isPreferedDarkModeByUser,
-  isDarkMode,
-  applyColorScheme,
-  removeUserPreference,
-  updateUserPreference,
-  updateUserPreferenceWithOsSettings,
-};

+ 5 - 6
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -4,14 +4,13 @@ import { useTranslation } from 'next-i18next';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
-
-const isDarkMode = isDarkModeByUtil();
-const colorText = isDarkMode ? 'dark' : 'light';
+import { useNextThemes } from '~/stores/use-next-themes';
 
 const CustomizeLayoutSetting = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const { resolvedTheme } = useNextThemes();
+
   const [isContainerFluid, setIsContainerFluid] = useState(false);
   const [retrieveError, setRetrieveError] = useState();
 
@@ -54,7 +53,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
                 onClick={() => setIsContainerFluid(false)}
                 role="button"
               >
-                <img src={`/images/customize-settings/default-${colorText}.svg`} />
+                <img src={`/images/customize-settings/default-${resolvedTheme}.svg`} />
                 <div className="card-body text-center">
                   {t('admin:customize_setting.layout_options.default')}
                 </div>
@@ -64,7 +63,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
                 onClick={() => setIsContainerFluid(true)}
                 role="button"
               >
-                <img src={`/images/customize-settings/fluid-${colorText}.svg`} />
+                <img src={`/images/customize-settings/fluid-${resolvedTheme}.svg`} />
                 <div className="card-body  text-center">
                   {t('admin:customize_setting.layout_options.expanded')}
                 </div>

+ 4 - 5
packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -4,8 +4,8 @@ import { useTranslation } from 'next-i18next';
 import { Card, CardBody } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
 import { useSWRxSidebarConfig } from '~/stores/ui';
+import { useNextThemes } from '~/stores/use-next-themes';
 
 const CustomizeSidebarsetting = (): JSX.Element => {
   const { t } = useTranslation();
@@ -13,10 +13,9 @@ const CustomizeSidebarsetting = (): JSX.Element => {
     update, isSidebarDrawerMode, isSidebarClosedAtDockMode, setIsSidebarDrawerMode, setIsSidebarClosedAtDockMode,
   } = useSWRxSidebarConfig();
 
-  const isDarkMode = isDarkModeByUtil();
-  const colorText = isDarkMode ? 'dark' : 'light';
-  const drawerIconFileName = `/images/customize-settings/drawer-${colorText}.svg`;
-  const dockIconFileName = `/images/customize-settings/dock-${colorText}.svg`;
+  const { resolvedTheme } = useNextThemes();
+  const drawerIconFileName = `/images/customize-settings/drawer-${resolvedTheme}.svg`;
+  const dockIconFileName = `/images/customize-settings/dock-${resolvedTheme}.svg`;
 
   const onClickSubmit = useCallback(async() => {
     try {

+ 17 - 16
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -1,10 +1,11 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 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';
 
@@ -12,37 +13,37 @@ import ThemeColorBox from './ThemeColorBox';
 
 /* eslint-disable no-multi-spaces */
 const lightNDarkTheme = [{
-  name: 'default',    bg: '#ffffff', topbar: '#2a2929', sidebar: '#122c55', theme: '#209fd8',
+  name: GrowiThemes.DEFAULT,      bg: '#ffffff', topbar: '#2a2929', sidebar: '#122c55', theme: '#209fd8',
 }, {
-  name: 'mono-blue',  bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
+  name: GrowiThemes.MONO_BLUE,    bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
 }, {
-  name: 'hufflepuff',  bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
+  name: GrowiThemes.HUFFLEPUFF,   bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
 }, {
-  name: 'fire-red',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#EA5532',
+  name: GrowiThemes.FIRE_RED,     bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#EA5532',
 }, {
-  name: 'jade-green',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#38B48B',
+  name: GrowiThemes.JADE_GREEN,   bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#38B48B',
 }];
 
 const uniqueTheme = [{
-  name: 'nature',     bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', theme: '#460039',
+  name: GrowiThemes.NATURE,       bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', theme: '#460039',
 }, {
-  name: 'wood',       bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', theme: '#aaa45f',
+  name: GrowiThemes.WOOD,         bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', theme: '#aaa45f',
 }, {
-  name: 'island',     bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', theme: 'rgba(183, 226, 219, 1)',
+  name: GrowiThemes.ISLAND,       bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', theme: 'rgba(183, 226, 219, 1)',
 }, {
-  name: 'christmas',  bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', theme: '#d3c665',
+  name: GrowiThemes.CHRISTMAS,    bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', theme: '#d3c665',
 }, {
-  name: 'antarctic',  bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', theme: '#fa9913',
+  name: GrowiThemes.ANTARCTIC,    bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', theme: '#fa9913',
 }, {
-  name: 'spring',     bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', theme: '#67a856',
+  name: GrowiThemes.SPRING,       bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', theme: '#67a856',
 }, {
-  name: 'future',     bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
+  name: GrowiThemes.FUTURE,       bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
 }, {
-  name: 'halloween',  bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
+  name: GrowiThemes.HALLOWEEN,    bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
 }, {
-  name: 'kibela',  bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
+  name: GrowiThemes.KIBELA,       bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
 }, {
-  name: 'blackboard',  bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', theme: '#DA8506',
+  name: GrowiThemes.BLACKBOARD,   bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', theme: '#DA8506',
 }];
 
 

+ 14 - 3
packages/app/src/components/Layout/RawLayout.tsx

@@ -1,7 +1,12 @@
 import React, { ReactNode } from 'react';
 
+import { useTheme } from 'next-themes';
 import Head from 'next/head';
 
+import { useGrowiTheme } from '~/stores/context';
+
+import { ThemeProvider } from '../Theme/utils/ThemeProvider';
+
 type Props = {
   title: string,
   className?: string,
@@ -14,6 +19,10 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   if (className != null) {
     classNames.push(className);
   }
+  const { data: growiTheme } = useGrowiTheme();
+
+  // get color scheme from next-themes
+  const { resolvedTheme: colorScheme } = useTheme();
 
   return (
     <>
@@ -22,9 +31,11 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
         <meta charSet="utf-8" />
         <meta name="viewport" content="initial-scale=1.0, width=device-width" />
       </Head>
-      <div className={classNames.join(' ')}>
-        {children}
-      </div>
+      <ThemeProvider theme={growiTheme}>
+        <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
+          {children}
+        </div>
+      </ThemeProvider>
     </>
   );
 };

+ 12 - 28
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -1,5 +1,5 @@
 import React, {
-  FC, useState, useCallback, useRef,
+  FC, useCallback, useRef,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
@@ -7,15 +7,8 @@ import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
-import {
-  isUserPreferenceExists,
-  isDarkMode as isDarkModeByUtil,
-  applyColorScheme,
-  removeUserPreference,
-  updateUserPreference,
-  updateUserPreferenceWithOsSettings,
-} from '~/client/util/color-scheme';
 import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
+import { Themes, useNextThemes } from '~/stores/use-next-themes';
 
 import MoonIcon from '../Icons/MoonIcon';
 import SidebarDockIcon from '../Icons/SidebarDockIcon';
@@ -31,9 +24,9 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
 
   const { isAuthenticated } = props;
 
-  const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
-  const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
-
+  const {
+    setTheme, resolvedTheme, useOsSettings, isDarkMode,
+  } = useNextThemes();
   const { data: isPreferDrawerMode, update: updatePreferDrawerMode } = usePreferDrawerModeByUser();
   const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
   const { scheduleToPut } = useUserUISettings();
@@ -52,27 +45,18 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
     }
   }, [updatePreferDrawerMode, mutatePreferDrawerModeOnEdit, scheduleToPut]);
 
-  const followOsCheckboxModifiedHandler = useCallback((useOsSettings: boolean) => {
-    if (useOsSettings) {
-      removeUserPreference();
+  const followOsCheckboxModifiedHandler = useCallback((isChecked: boolean) => {
+    if (isChecked) {
+      setTheme(Themes.system);
     }
     else {
-      updateUserPreferenceWithOsSettings();
+      setTheme(resolvedTheme ?? Themes.light);
     }
-    applyColorScheme();
-
-    // update states
-    setOsSettings(useOsSettings);
-    setIsDarkMode(isDarkModeByUtil());
-  }, []);
+  }, [resolvedTheme, setTheme]);
 
   const userPreferenceSwitchModifiedHandler = useCallback((isDarkMode: boolean) => {
-    updateUserPreference(isDarkMode);
-    applyColorScheme();
-
-    // update state
-    setIsDarkMode(isDarkModeByUtil());
-  }, []);
+    setTheme(isDarkMode ? 'dark' : 'light');
+  }, [setTheme]);
 
   /* eslint-disable react/prop-types */
   const IconWithTooltip = ({

+ 4 - 3
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -3,7 +3,7 @@ import React, { FC } from 'react';
 import { Picker } from 'emoji-mart';
 import { Modal } from 'reactstrap';
 
-import { isDarkMode } from '~/client/util/color-scheme';
+import { useNextThemes } from '~/stores/use-next-themes';
 
 import EmojiPickerHelper, { getEmojiTranslation } from './EmojiPickerHelper';
 
@@ -20,6 +20,8 @@ const EmojiPicker: FC<Props> = (props: Props) => {
     onClose, emojiSearchText, emojiPickerHelper, isOpen,
   } = props;
 
+  const { resolvedTheme } = useNextThemes();
+
   // Set search emoji input and trigger search
   const searchEmoji = () => {
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
@@ -42,7 +44,6 @@ const EmojiPicker: FC<Props> = (props: Props) => {
 
 
   const translation = getEmojiTranslation();
-  const theme = isDarkMode() ? 'dark' : 'light';
 
   return (
     <Modal isOpen={isOpen} toggle={onClose} onOpened={searchEmoji} backdropClassName="emoji-picker-modal" fade={false}>
@@ -52,7 +53,7 @@ const EmojiPicker: FC<Props> = (props: Props) => {
         title={translation.title}
         emojiTooltip
         style={emojiPickerHelper.setStyle()}
-        theme={theme}
+        theme={resolvedTheme}
       />
     </Modal>
   );

+ 7 - 7
packages/app/src/styles/theme/antarctic.scss → packages/app/src/components/Theme/ThemeAntarctic.module.scss

@@ -1,5 +1,6 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
 
 // == Define Bootstrap theme colors
 //
@@ -43,8 +44,7 @@ $accentcolor: #ffd700;
 
 //== Light Mode
 //
-html[light],
-html[dark] {
+.theme :global {
   $primary: $themecolor;
 
   // Background colors
@@ -110,13 +110,13 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: lighten($themecolor, 20%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   //Button
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager(darken($primary, 10%), lighten($primary, 55%), lighten($primary, 60%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager(darken($primary, 10%), lighten($primary, 55%), lighten($primary, 60%));
     }
   }
 

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

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

+ 7 - 7
packages/app/src/styles/theme/blackboard.scss → packages/app/src/components/Theme/ThemeBlackboard.module.scss

@@ -1,8 +1,8 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
 
-html[light],
-html[dark] {
+.theme :global {
   // Theme colors
   $themecolor: #da8506;
   $themelight: #223729;
@@ -79,8 +79,8 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: $primary;
 
-  @import 'apply-colors';
-  @import 'apply-colors-dark';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-dark';
 
   // Navs
   .nav-tabs {
@@ -108,7 +108,7 @@ html[dark] {
   // Button
   .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager(#ffffff, $primary, $primary, darken($primary, 20%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager(#ffffff, $primary, $primary, darken($primary, 20%));
     }
   }
 }

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

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

+ 7 - 7
packages/app/src/styles/theme/christmas.scss → packages/app/src/components/Theme/ThemeChristmas.module.scss

@@ -1,5 +1,6 @@
-@import '../variables';
-@import '../override-bootstrap-variables';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
 
 // == Define Bootstrap theme colors
 //
@@ -37,8 +38,7 @@ $color-link-wiki-hover: lighten($color-link-wiki, 15%);
 
 //== Light Mode
 //
-html[light],
-html[dark] {
+.theme :global {
   $primary: #d3c665;
   // Background colors
   $bgcolor-card: $gray-50;
@@ -102,8 +102,8 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: lighten($themecolor, 20%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   // change color of highlighted header in wiki (default: orange)
 
@@ -176,7 +176,7 @@ html[dark] {
   // Button
   .grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include btn-page-editor-mode-manager(darken($subthemecolor, 15%), lighten($subthemecolor, 35%), lighten($subthemecolor, 45%));
+      @include page-editor-mode-manager.btn-page-editor-mode-manager(darken($subthemecolor, 15%), lighten($subthemecolor, 35%), lighten($subthemecolor, 45%));
     }
   }
 }

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

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

+ 9 - 9
packages/app/src/styles/theme/default.scss → packages/app/src/components/Theme/ThemeDefault.module.scss

@@ -1,6 +1,6 @@
-@use '../variables' as *;
-@use '../bootstrap/variables' as *;
-@use './mixins/page-editor-mode-manager';
+@use '../../styles/variables' as *;
+@use '../../styles/bootstrap/variables' as *;
+@use '../../styles/theme/mixins/page-editor-mode-manager';
 
 // == Define Bootstrap theme colors
 //
@@ -16,7 +16,7 @@
 
 //== Light Mode
 //
-html[light] {
+.theme[data-color-scheme='light'] :global {
   $primary: #122c55;
   $accent: #209fd8;
 
@@ -103,8 +103,8 @@ html[light] {
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);
 
-  @import 'apply-colors';
-  @import 'apply-colors-light';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-light';
 
   // Button
   .btn-group.grw-page-editor-mode-manager {
@@ -116,7 +116,7 @@ html[light] {
 
 //== Dark Mode
 //
-html[dark] {
+.theme[data-color-scheme='dark'] :global {
   $primary: #115cd3;
   $accent: #db00c2;
 
@@ -200,8 +200,8 @@ html[dark] {
   // admin theme box
   $color-theme-color-box: $primary;
 
-  @import 'apply-colors';
-  @import 'apply-colors-dark';
+  @import '../../styles/theme/apply-colors';
+  @import '../../styles/theme/apply-colors-dark';
 
   //Button
   .btn-group.grw-page-editor-mode-manager {

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

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

+ 0 - 0
packages/app/src/styles/theme/fire-red.scss → packages/app/src/components/Theme/ThemeFireRed.module.scss


+ 0 - 0
packages/app/src/styles/theme/future.scss → packages/app/src/components/Theme/ThemeFuture.module.scss


+ 0 - 0
packages/app/src/styles/theme/halloween.scss → packages/app/src/components/Theme/ThemeHalloween.module.scss


+ 0 - 0
packages/app/src/styles/theme/hufflepuff.scss → packages/app/src/components/Theme/ThemeHufflepuff.module.scss


+ 0 - 0
packages/app/src/styles/theme/island.scss → packages/app/src/components/Theme/ThemeIsland.module.scss


+ 0 - 0
packages/app/src/styles/theme/jade-green.scss → packages/app/src/components/Theme/ThemeJadeGreen.module.scss


+ 0 - 0
packages/app/src/styles/theme/kibela.scss → packages/app/src/components/Theme/ThemeKibela.module.scss


+ 0 - 0
packages/app/src/styles/theme/mono-blue.scss → packages/app/src/components/Theme/ThemeMonoBlue.module.scss


+ 0 - 0
packages/app/src/styles/theme/nature.scss → packages/app/src/components/Theme/ThemeNature.module.scss


+ 0 - 0
packages/app/src/styles/theme/spring.scss → packages/app/src/components/Theme/ThemeSpring.module.scss


+ 0 - 0
packages/app/src/styles/theme/wood.scss → packages/app/src/components/Theme/ThemeWood.module.scss


+ 12 - 0
packages/app/src/components/Theme/utils/ThemeInjector.tsx

@@ -0,0 +1,12 @@
+
+import React from 'react';
+
+type Props = {
+  children: JSX.Element,
+  className: string,
+}
+
+export const ThemeInjector = ({ children, className: themeClassName }: Props): JSX.Element => {
+  const className = `${children.props.className ?? ''} ${themeClassName}`;
+  return React.cloneElement(children, { className });
+};

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

@@ -0,0 +1,31 @@
+
+import React from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { GrowiThemes } from '~/interfaces/theme';
+
+
+const ThemeAntarctic = dynamic(() => import('../ThemeAntarctic'));
+const ThemeBlackboard = dynamic(() => import('../ThemeBlackboard'));
+const ThemeChristmas = dynamic(() => import('../ThemeChristmas'));
+const ThemeDefault = dynamic(() => import('../ThemeDefault'));
+
+
+type Props = {
+  children: JSX.Element,
+  theme?: GrowiThemes,
+}
+
+export const ThemeProvider = ({ theme, children }: Props): JSX.Element => {
+  switch (theme) {
+    case GrowiThemes.ANTARCTIC:
+      return <ThemeAntarctic>{children}</ThemeAntarctic>;
+    case GrowiThemes.BLACKBOARD:
+      return <ThemeBlackboard>{children}</ThemeBlackboard>;
+    case GrowiThemes.CHRISTMAS:
+      return <ThemeChristmas>{children}</ThemeChristmas>;
+    default:
+      return <ThemeDefault>{children}</ThemeDefault>;
+  }
+};

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

@@ -0,0 +1,18 @@
+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];

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

@@ -50,7 +50,7 @@ import {
   useCurrentUser, useCurrentPagePath,
   useIsLatestRevision,
   useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
-  useAppTitle, useSiteUrl, useConfidential, useIsEnabledStaleNotification, useIsIdenticalPath,
+  useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useHackmdUri,
   useIsAclEnabled, useIsUserPage, useIsNotCreatable,
@@ -141,11 +141,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   }
 
   // commons
-  useAppTitle(props.appTitle);
-  useSiteUrl(props.siteUrl);
   useXss(new Xss());
   // useEditorConfig(props.editorConfig);
-  useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
 
   // UserUISettings

+ 14 - 5
packages/app/src/pages/_app.page.tsx

@@ -1,17 +1,20 @@
 import React, { useEffect } from 'react';
 
 import { appWithTranslation } from 'next-i18next';
+import { ThemeProvider } from 'next-themes';
 import { AppProps } from 'next/app';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
 import '~/styles/style-next.scss';
-import '~/styles/theme/default.scss';
+// import '~/styles/theme/default.scss';
 // import InterceptorManager from '~/service/interceptor-manager';
 
 import * as nextI18nConfig from '../next-i18next.config';
 import { useI18nextHMR } from '../services/i18next-hmr';
-import { useGrowiVersion } from '../stores/context';
+import {
+  useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl,
+} from '../stores/context';
 
 import { CommonProps } from './commons';
 // import { useInterceptorManager } from '~/stores/interceptor';
@@ -31,12 +34,18 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
 
   const commonPageProps = pageProps as CommonProps;
   // useInterceptorManager(new InterceptorManager());
+  useAppTitle(commonPageProps.appTitle);
+  useSiteUrl(commonPageProps.siteUrl);
+  useConfidential(commonPageProps.confidential);
+  useGrowiTheme(commonPageProps.theme);
   useGrowiVersion(commonPageProps.growiVersion);
 
   return (
-    <DndProvider backend={HTML5Backend}>
-      <Component {...pageProps} />
-    </DndProvider>
+    <ThemeProvider>
+      <DndProvider backend={HTML5Backend}>
+        <Component {...pageProps} />
+      </DndProvider>
+    </ThemeProvider>
   );
 }
 

+ 4 - 1
packages/app/src/pages/commons.ts

@@ -3,6 +3,7 @@ import { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { SSRConfig, UserConfig } from 'next-i18next';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
+import { GrowiThemes } from '~/interfaces/theme';
 
 import * as nextI18NextConfig from '../next-i18next.config';
 
@@ -12,6 +13,7 @@ export type CommonProps = {
   appTitle: string,
   siteUrl: string,
   confidential: string,
+  theme: GrowiThemes,
   customTitleTemplate: string,
   csrfToken: string,
   growiVersion: string,
@@ -23,7 +25,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const {
-    appService, customizeService,
+    appService, configManager, customizeService,
   } = crowi;
 
   const url = new URL(context.resolvedUrl, 'http://example.com');
@@ -35,6 +37,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     appTitle: appService.getAppTitle(),
     siteUrl: appService.getSiteUrl(),
     confidential: appService.getAppConfidential() || '',
+    theme: configManager.getConfig('crowi', 'customize:theme'),
     customTitleTemplate: customizeService.customTitleTemplate,
     csrfToken: req.csrfToken(),
     growiVersion: crowi.version,

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

@@ -2,6 +2,8 @@ import { getOrCreateModel } from '@growi/core';
 import { Types, Schema } from 'mongoose';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import { GrowiThemes } from '~/interfaces/theme';
+
 
 export interface Config {
   _id: Types.ObjectId;
@@ -123,7 +125,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:title' : undefined,
   'customize:highlightJsStyle' : 'github',
   'customize:highlightJsStyleBorder' : false,
-  'customize:theme' : 'default',
+  'customize:theme' : GrowiThemes.DEFAULT,
   'customize:isContainerFluid' : false,
   'customize:isEnabledTimeline' : true,
   'customize:isSavedStatesOfTabChanges' : true,

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

@@ -7,6 +7,7 @@ import useSWRImmutable from 'swr/immutable';
 import { SupportedActionType } from '~/interfaces/activity';
 // import { CustomWindow } from '~/interfaces/global';
 import { RendererConfig } from '~/interfaces/services/renderer';
+import { GrowiThemes } from '~/interfaces/theme';
 import InterceptorManager from '~/services/interceptor-manager';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
@@ -38,6 +39,10 @@ export const useConfidential = (initialData?: string): SWRResponse<string, Error
   return useStaticSWR('confidential', initialData);
 };
 
+export const useGrowiTheme = (initialData?: GrowiThemes): SWRResponse<GrowiThemes, Error> => {
+  return useStaticSWR('theme', initialData);
+};
+
 export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nullable<IUser>, Error> => {
   return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData);
 };

+ 22 - 0
packages/app/src/stores/use-next-themes.ts

@@ -0,0 +1,22 @@
+import { useTheme } from 'next-themes';
+import { UseThemeProps } from 'next-themes/dist/types';
+
+export const Themes = {
+  light: 'light',
+  dark: 'dark',
+  system: 'system',
+} as const;
+export type Themes = typeof Themes[keyof typeof Themes];
+
+export type NextThemesComputedValues = {
+  useOsSettings: boolean,
+  isDarkMode: boolean,
+}
+
+export const useNextThemes = (): UseThemeProps & NextThemesComputedValues => {
+  const props = useTheme();
+  return Object.assign(props, {
+    useOsSettings: props.theme === Themes.system,
+    isDarkMode: props.resolvedTheme === Themes.dark,
+  });
+};

+ 5 - 0
yarn.lock

@@ -14735,6 +14735,11 @@ next-i18next@^11.0.0:
     i18next-fs-backend "^1.1.4"
     react-i18next "^11.16.2"
 
+next-themes@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.0.tgz#fdc507f61e95b3ae513dee8d4783bcec8c02e3a3"
+  integrity sha512-myhpDL4vadBD9YDSHiewqvzorGzB03N84e+3LxCwHRlM/hiBOaW+UsKsQojQAzC7fdcJA0l2ppveXcYaVV+hxQ==
+
 next-transpile-modules@^9.0.0:
   version "9.0.0"
   resolved "https://registry.yarnpkg.com/next-transpile-modules/-/next-transpile-modules-9.0.0.tgz#133b1742af082e61cc76b02a0f12ffd40ce2bf90"