Răsfoiți Sursa

Merge branch 'imprv/97535-omit-tag-container' into fix/98146-revision-err-related-tag-edt

kaori 3 ani în urmă
părinte
comite
d3213bd50b
34 a modificat fișierele cu 147 adăugiri și 175 ștergeri
  1. 0 0
      packages/app/public/static/locales/en_US/admin/admin.json
  2. 0 0
      packages/app/public/static/locales/en_US/meta.json
  3. 0 0
      packages/app/public/static/locales/en_US/translation.json
  4. 0 0
      packages/app/public/static/locales/index.js
  5. 0 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  6. 0 0
      packages/app/public/static/locales/ja_JP/meta.json
  7. 0 0
      packages/app/public/static/locales/ja_JP/translation.json
  8. 0 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  9. 0 0
      packages/app/public/static/locales/zh_CN/meta.json
  10. 0 0
      packages/app/public/static/locales/zh_CN/translation.json
  11. 36 40
      packages/app/src/client/admin.jsx
  12. 7 1
      packages/app/src/client/services/AppContainer.js
  13. 0 1
      packages/app/src/client/util/editor.ts
  14. 3 2
      packages/app/src/client/util/i18n.js
  15. 7 7
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  16. 2 4
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  17. 2 3
      packages/app/src/components/Page.jsx
  18. 1 1
      packages/app/src/components/Page/TagLabels.tsx
  19. 14 18
      packages/app/src/components/Page/TagsInput.tsx
  20. 2 2
      packages/app/src/components/PageEditor.tsx
  21. 3 3
      packages/app/src/components/PageEditorByHackmd.jsx
  22. 2 1
      packages/app/src/components/SavePageControls.jsx
  23. 10 0
      packages/app/src/next-i18next.config.ts
  24. 5 4
      packages/app/src/server/crowi/dev.js
  25. 5 4
      packages/app/src/server/crowi/express-init.js
  26. 2 8
      packages/app/src/server/models/user.js
  27. 3 4
      packages/app/src/server/routes/apiv3/app-settings.js
  28. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  29. 4 2
      packages/app/src/server/service/file-uploader/gcs.js
  30. 23 12
      packages/app/src/server/service/passport.ts
  31. 5 4
      packages/app/src/stores/editor.tsx
  32. 8 1
      packages/app/src/stores/tag.tsx
  33. 1 1
      packages/app/src/styles/_subnav.scss
  34. 0 50
      packages/app/src/utils/locale-utils.ts

+ 0 - 0
packages/app/resource/locales/en_US/admin/admin.json → packages/app/public/static/locales/en_US/admin/admin.json


+ 0 - 0
packages/app/resource/locales/en_US/meta.json → packages/app/public/static/locales/en_US/meta.json


+ 0 - 0
packages/app/resource/locales/en_US/translation.json → packages/app/public/static/locales/en_US/translation.json


+ 0 - 0
packages/app/resource/locales/index.js → packages/app/public/static/locales/index.js


+ 0 - 0
packages/app/resource/locales/ja_JP/admin/admin.json → packages/app/public/static/locales/ja_JP/admin/admin.json


+ 0 - 0
packages/app/resource/locales/ja_JP/meta.json → packages/app/public/static/locales/ja_JP/meta.json


+ 0 - 0
packages/app/resource/locales/ja_JP/translation.json → packages/app/public/static/locales/ja_JP/translation.json


+ 0 - 0
packages/app/resource/locales/zh_CN/admin/admin.json → packages/app/public/static/locales/zh_CN/admin/admin.json


+ 0 - 0
packages/app/resource/locales/zh_CN/meta.json → packages/app/public/static/locales/zh_CN/meta.json


+ 0 - 0
packages/app/resource/locales/zh_CN/translation.json → packages/app/public/static/locales/zh_CN/translation.json


+ 36 - 40
packages/app/src/client/admin.jsx

@@ -1,55 +1,52 @@
 import React from 'react';
 import React from 'react';
+
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
-
 import { SWRConfig } from 'swr';
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
 
-import loggerFactory from '~/utils/logger';
-import { swrGlobalConfiguration } from '~/utils/swr-utils';
-
-import ErrorBoundary from '../components/ErrorBoudary';
-
-import AdminHome from '../components/Admin/AdminHome/AdminHome';
-import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
-import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
-import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
-import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
-import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
-import UserManagement from '../components/Admin/UserManagement';
-import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
-import SecurityManagement from '../components/Admin/Security/SecurityManagement';
-import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
-import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
-import Customize from '../components/Admin/Customize/Customize';
-import ImportDataPage from '../components/Admin/ImportDataPage';
-import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
-import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
-import AdminNavigation from '../components/Admin/Common/AdminNavigation';
-
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-
+import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
+import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import AdminHome from '../components/Admin/AdminHome/AdminHome';
+import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import AdminNavigation from '../components/Admin/Common/AdminNavigation';
+import Customize from '../components/Admin/Customize/Customize';
+import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
+import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
+import ImportDataPage from '../components/Admin/ImportDataPage';
+import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
+import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
+import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
+import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
+import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
+import SecurityManagement from '../components/Admin/Security/SecurityManagement';
+import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
+import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
+import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
+import UserManagement from '../components/Admin/UserManagement';
+import ErrorBoundary from '../components/ErrorBoudary';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 
 
@@ -58,7 +55,6 @@ const logger = loggerFactory('growi:admin');
 appContainer.initContents();
 appContainer.initContents();
 
 
 const { i18n } = appContainer;
 const { i18n } = appContainer;
-
 // create unstated container instance
 // create unstated container instance
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);

+ 7 - 1
packages/app/src/client/services/AppContainer.js

@@ -15,7 +15,13 @@ export default class AppContainer extends Container {
 
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
 
-    const userLocaleId = this.currentUser?.lang;
+    // init i18n
+    const currentUserElem = document.getElementById('growi-current-user');
+    let userLocaleId;
+    if (currentUserElem != null) {
+      const currentUser = JSON.parse(currentUserElem.textContent);
+      userLocaleId = currentUser?.lang;
+    }
     this.i18n = i18nFactory(userLocaleId);
     this.i18n = i18nFactory(userLocaleId);
 
 
     this.containerInstances = {};
     this.containerInstances = {};

+ 0 - 1
packages/app/src/client/util/editor.ts

@@ -7,7 +7,6 @@ type OptionsToSave = {
   grantUserGroupName?: string | null;
   grantUserGroupName?: string | null;
 };
 };
 
 
-// TODO: Remove editorContainer upon migration to SWR
 export const getOptionsToSave = (
 export const getOptionsToSave = (
     isSlackEnabled: boolean,
     isSlackEnabled: boolean,
     slackChannels: string,
     slackChannels: string,

+ 3 - 2
packages/app/src/client/util/i18n.js

@@ -1,7 +1,8 @@
 import i18n from 'i18next';
 import i18n from 'i18next';
 import LanguageDetector from 'i18next-browser-languagedetector';
 import LanguageDetector from 'i18next-browser-languagedetector';
 import { initReactI18next } from 'react-i18next';
 import { initReactI18next } from 'react-i18next';
-import locales from '^/resource/locales';
+
+import locales from '^/public/static/locales';
 
 
 const aliasesMapping = {};
 const aliasesMapping = {};
 Object.values(locales).forEach((locale) => {
 Object.values(locales).forEach((locale) => {
@@ -13,7 +14,7 @@ Object.values(locales).forEach((locale) => {
   });
   });
 });
 });
 
 
-// extract metadata list from 'resource/locales/${locale}/meta.json'
+// extract metadata list from 'public/static/locales/${locale}/meta.json'
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);
 
 
 export const i18nFactory = (userLocaleId) => {
 export const i18nFactory = (userLocaleId) => {

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

@@ -170,7 +170,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
 
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(pageId);
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(pageId);
-  const { data: tagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors();
+  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(pageId);
 
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openRenameModal } = usePageRenameModal();
@@ -178,7 +178,7 @@ const GrowiContextualSubNavigation = (props) => {
 
 
   useEffect(() => {
   useEffect(() => {
     // Run only when tagsInfoData has been updated
     // Run only when tagsInfoData has been updated
-    syncPageTagsForEditors(tagsInfoData?.tags);
+    syncPageTagsForEditors();
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [tagsInfoData?.tags]);
   }, [tagsInfoData?.tags]);
 
 
@@ -199,7 +199,7 @@ const GrowiContextualSubNavigation = (props) => {
 
 
       // revalidate SWRTagsInfo
       // revalidate SWRTagsInfo
       mutateSWRTagsInfo();
       mutateSWRTagsInfo();
-      syncPageTagsForEditors(newTags);
+      mutatePageTagsForEditors(newTags);
 
 
       toastSuccess('updated tags successfully');
       toastSuccess('updated tags successfully');
     }
     }
@@ -207,13 +207,13 @@ const GrowiContextualSubNavigation = (props) => {
       toastError(err, 'fail to update tags');
       toastError(err, 'fail to update tags');
     }
     }
 
 
-  }, [pageId, revisionId, pageContainer, mutateSWRTagsInfo, syncPageTagsForEditors]);
+  }, [pageId, revisionId, mutateSWRTagsInfo, mutatePageTagsForEditors, pageContainer]);
 
 
-  const tagsUpdatedHandlerForEditMode = useCallback(async(newTags: string[]) => {
+  const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
     // It will not be reflected in the DB until the page is refreshed
     // It will not be reflected in the DB until the page is refreshed
-    syncPageTagsForEditors(newTags);
+    mutatePageTagsForEditors(newTags);
     return;
     return;
-  }, [syncPageTagsForEditors]);
+  }, [mutatePageTagsForEditors]);
 
 
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {

+ 2 - 4
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -2,7 +2,6 @@ import React from 'react';
 
 
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
 import { IUser } from '~/interfaces/user';
-import { useCurrentPagePath } from '~/stores/context';
 import {
 import {
   EditorMode, useEditorMode,
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -26,7 +25,7 @@ type Props = {
   isCompactMode?: boolean,
   isCompactMode?: boolean,
 
 
   tags?: string[],
   tags?: string[],
-  tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
+  tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
 
 
   controls?: React.FunctionComponent,
   controls?: React.FunctionComponent,
   additionalClasses?: string[],
   additionalClasses?: string[],
@@ -34,7 +33,6 @@ type Props = {
 
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
-  const { data: currentPath } = useCurrentPagePath();
 
 
   const {
   const {
     page,
     page,
@@ -74,7 +72,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
 
 
         <div className="grw-path-nav-container">
         <div className="grw-path-nav-container">
           {/* "/trash" page does not exist on page collection and unable to add tags  */}
           {/* "/trash" page does not exist on page collection and unable to add tags  */}
-          { showTagLabel && !isCompactMode && currentPath !== '/trash' && (
+          { showTagLabel && !isCompactMode && path !== '/trash' && (
             <div className="grw-taglabels-container">
             <div className="grw-taglabels-container">
               <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
               <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
             </div>

+ 2 - 3
packages/app/src/components/Page.jsx

@@ -9,10 +9,9 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
-  useCurrentPagePath, useIsGuestUser, useCurrentPageId,
+  useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
-import { useSWRxTagsInfo } from '~/stores/page';
 import {
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -191,7 +190,7 @@ const PageWrapper = (props) => {
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags, sync: syncPageTagsForEditors } = usePageTagsForEditors();
+  const { data: pageTags } = usePageTagsForEditors();
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName } = useSelectedGrantGroupName();

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

@@ -6,7 +6,7 @@ import TagEditModal from './TagEditModal';
 type Props = {
 type Props = {
   tags?: string[],
   tags?: string[],
   isGuestUser: boolean,
   isGuestUser: boolean,
-  tagsUpdateInvoked?: (tags: string[]) => Promise<void>,
+  tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
 }
 }
 
 
 
 

+ 14 - 18
packages/app/src/components/Page/TagsInput.tsx

@@ -1,11 +1,10 @@
 import React, {
 import React, {
   FC, useRef, useState, useCallback,
   FC, useRef, useState, useCallback,
 } from 'react';
 } from 'react';
+
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 
-import { apiGet } from '~/client/util/apiv1-client';
-import { toastError } from '~/client/util/apiNotification';
-import { IResTagsSearchApiv1 } from '~/interfaces/tag';
+import { useSWRxTagsSearch } from '~/stores/tag';
 
 
 type TypeaheadInstance = {
 type TypeaheadInstance = {
   _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
   _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
@@ -24,7 +23,11 @@ const TagsInput: FC<Props> = (props: Props) => {
   const tagsInputRef = useRef<TypeaheadInstance>(null);
   const tagsInputRef = useRef<TypeaheadInstance>(null);
 
 
   const [resultTags, setResultTags] = useState<string[]>([]);
   const [resultTags, setResultTags] = useState<string[]>([]);
-  const [isLoading, setLoading] = useState(false);
+  const [searchQuery, setSearchQuery] = useState('');
+
+  const { data: tagsSearch, error } = useSWRxTagsSearch(searchQuery);
+
+  const isLoading = error == null && tagsSearch === undefined;
 
 
   const changeHandler = useCallback((selected: string[]) => {
   const changeHandler = useCallback((selected: string[]) => {
     if (props.onTagsUpdated != null) {
     if (props.onTagsUpdated != null) {
@@ -33,20 +36,13 @@ const TagsInput: FC<Props> = (props: Props) => {
   }, [props]);
   }, [props]);
 
 
   const searchHandler = useCallback(async(query: string) => {
   const searchHandler = useCallback(async(query: string) => {
-    setLoading(true);
-    try {
-      // TODO: 91698 SWRize
-      const res = await apiGet('/tags.search', { q: query }) as IResTagsSearchApiv1;
-      res.tags.unshift(query);
-      setResultTags(Array.from(new Set(res.tags)));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setLoading(false);
-    }
-  }, []);
+    const tagsSearchData = tagsSearch?.tags || [];
+    setSearchQuery(query);
+
+    tagsSearchData.unshift(searchQuery);
+    setResultTags(Array.from(new Set(tagsSearchData)));
+
+  }, [searchQuery, tagsSearch?.tags]);
 
 
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
     if (event.key === ' ') {
     if (event.key === ' ') {

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

@@ -19,7 +19,6 @@ import {
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
 } from '~/stores/editor';
 } from '~/stores/editor';
-import { useSWRxTagsInfo } from '~/stores/page';
 import {
 import {
   EditorMode,
   EditorMode,
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
@@ -87,7 +86,8 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags } = usePageTagsForEditors();
+  const { data: pageId } = useCurrentPageId();
+  const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();

+ 3 - 3
packages/app/src/components/PageEditorByHackmd.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -11,7 +11,6 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
-import { useSWRxTagsInfo } from '~/stores/page';
 import {
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -453,7 +452,8 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags } = usePageTagsForEditors();
+  const { data: pageId } = useCurrentPageId();
+  const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName } = useSelectedGrantGroupName();

+ 2 - 1
packages/app/src/components/SavePageControls.jsx

@@ -144,7 +144,8 @@ const SavePageControlsWrapper = (props) => {
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
-  const { data: pageTags } = usePageTagsForEditors();
+  const { data: pageId } = useCurrentPageId();
+  const { data: pageTags } = usePageTagsForEditors(pageId);
 
 
 
 
   if (isEditable == null || editorMode == null) {
   if (isEditable == null || editorMode == null) {

+ 10 - 0
packages/app/src/next-i18next.config.ts

@@ -0,0 +1,10 @@
+import path from 'path';
+
+export const
+  i18n = {
+    defaultLocale: 'en_US',
+    locales: ['ja_JP', 'zh_CN'],
+  };
+export const defaultNS = 'translation';
+export const localePath = path.resolve('./public/static/locales');
+export const allLocales = [i18n.defaultLocale].concat(i18n.locales);

+ 5 - 4
packages/app/src/server/crowi/dev.js

@@ -1,9 +1,10 @@
 import path from 'path';
 import path from 'path';
-import { listLocaleIds } from '~/utils/locale-utils';
+
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-const swig = require('swig-templates');
 const onHeaders = require('on-headers');
 const onHeaders = require('on-headers');
+const swig = require('swig-templates');
 
 
 const logger = loggerFactory('growi:crowi:dev');
 const logger = loggerFactory('growi:crowi:dev');
 
 
@@ -41,9 +42,9 @@ class CrowiDev {
    */
    */
   requireForAutoReloadServer() {
   requireForAutoReloadServer() {
     // load all json files for live reloading
     // load all json files for live reloading
-    listLocaleIds()
+    allLocales
       .forEach((localeId) => {
       .forEach((localeId) => {
-        require(path.join(this.crowi.localeDir, localeId, 'translation.json'));
+        require(path.join(this.crowi.publicDir, 'static/locales', localeId, 'translation.json'));
       });
       });
   }
   }
 
 

+ 5 - 4
packages/app/src/server/crowi/express-init.js

@@ -1,5 +1,7 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import { allLocales, localePath } from '~/next-i18next.config';
+
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
   const path = require('path');
@@ -24,7 +26,6 @@ module.exports = function(crowi, app) {
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
-  const { listLocaleIds } = require('~/utils/locale-utils');
 
 
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
@@ -41,9 +42,9 @@ module.exports = function(crowi, app) {
     .init({
     .init({
       // debug: true,
       // debug: true,
       fallbackLng: ['en_US'],
       fallbackLng: ['en_US'],
-      whitelist: listLocaleIds(),
+      whitelist: allLocales,
       backend: {
       backend: {
-        loadPath: `${crowi.localeDir}{{lng}}/translation.json`,
+        loadPath: `${localePath}/{{lng}}/translation.json`,
       },
       },
       detection: {
       detection: {
         order: ['userSettingDetector', 'header', 'navigator'],
         order: ['userSettingDetector', 'header', 'navigator'],
@@ -81,7 +82,7 @@ module.exports = function(crowi, app) {
     res.locals.consts = {
     res.locals.consts = {
       pageGrants: Page.getGrantLabels(),
       pageGrants: Page.getGrantLabels(),
       userStatus: User.getUserStatusLabels(),
       userStatus: User.getUserStatusLabels(),
-      language:   listLocaleIds(),
+      language:   allLocales,
       restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
       restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
       registrationMode: crowi.aclService.getRegistrationModeLabels(),
       registrationMode: crowi.aclService.getRegistrationModeLabels(),
     };
     };

+ 2 - 8
packages/app/src/server/models/user.js

@@ -1,4 +1,5 @@
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
+import { allLocales } from '~/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -6,15 +7,12 @@ import loggerFactory from '~/utils/logger';
 const crypto = require('crypto');
 const crypto = require('crypto');
 
 
 const debug = require('debug')('growi:models:user');
 const debug = require('debug')('growi:models:user');
-const md5 = require('md5');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
-const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
-
 const { omitInsecureAttributes } = require('./serializers/user-serializer');
 const { omitInsecureAttributes } = require('./serializers/user-serializer');
 
 
 const logger = loggerFactory('growi:models:user');
 const logger = loggerFactory('growi:models:user');
@@ -61,7 +59,7 @@ module.exports = function(crowi) {
     apiToken: { type: String, index: true },
     apiToken: { type: String, index: true },
     lang: {
     lang: {
       type: String,
       type: String,
-      enum: listLocaleIds(),
+      enum: allLocales,
       default: 'en_US',
       default: 'en_US',
     },
     },
     status: {
     status: {
@@ -78,10 +76,6 @@ module.exports = function(crowi) {
       },
       },
     },
     },
   });
   });
-  // eslint-disable-next-line prefer-arrow-callback
-  userSchema.pre('validate', function() {
-    this.lang = migrateDeprecatedLocaleId(this.lang);
-  });
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(uniqueValidator);
   userSchema.plugin(uniqueValidator);
 
 

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

@@ -1,4 +1,6 @@
 import { body } from 'express-validator';
 import { body } from 'express-validator';
+
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -6,11 +8,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 
 const debug = require('debug')('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
-
 const express = require('express');
 const express = require('express');
-
 const { pathUtils } = require('@growi/core');
 const { pathUtils } = require('@growi/core');
-const { listLocaleIds } = require('~/utils/locale-utils');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
@@ -157,7 +156,7 @@ module.exports = (crowi) => {
     appSetting: [
     appSetting: [
       body('title').trim(),
       body('title').trim(),
       body('confidential'),
       body('confidential'),
-      body('globalLang').isIn(listLocaleIds()),
+      body('globalLang').isIn(allLocales),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
       body('fileUpload').isBoolean(),
     ],
     ],

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

@@ -1,6 +1,6 @@
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
-import { listLocaleIds } from '~/utils/locale-utils';
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -83,7 +83,7 @@ module.exports = (crowi) => {
           if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
           if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
           return true;
           return true;
         }),
         }),
-      body('lang').isString().isIn(listLocaleIds()),
+      body('lang').isString().isIn(allLocales),
       body('isEmailPublished').isBoolean(),
       body('isEmailPublished').isBoolean(),
       body('slackMemberId').optional().isString(),
       body('slackMemberId').optional().isString(),
     ],
     ],

+ 4 - 2
packages/app/src/server/service/file-uploader/gcs.js

@@ -2,8 +2,8 @@ import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
 
-const urljoin = require('url-join');
 const { Storage } = require('@google-cloud/storage');
 const { Storage } = require('@google-cloud/storage');
+const urljoin = require('url-join');
 
 
 let _instance;
 let _instance;
 
 
@@ -21,7 +21,9 @@ module.exports = function(crowi) {
     if (_instance == null) {
     if (_instance == null) {
       const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
       const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
       // see https://googleapis.dev/nodejs/storage/latest/Storage.html
       // see https://googleapis.dev/nodejs/storage/latest/Storage.html
-      _instance = new Storage({ keyFilename });
+      _instance = keyFilename != null
+        ? new Storage({ keyFilename }) // Create a client with explicit credentials
+        : new Storage(); // Create a client that uses Application Default Credentials
     }
     }
     return _instance;
     return _instance;
   }
   }

+ 23 - 12
packages/app/src/server/service/passport.ts

@@ -638,7 +638,7 @@ class PassportService implements S2sMessageHandlable {
       : configManager.getConfig('crowi', 'security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
       : configManager.getConfig('crowi', 'security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
 
 
     // Prevent request timeout error on app init
     // Prevent request timeout error on app init
-    const oidcIssuer = await this.getOIDCIssuerInstace(issuerHost);
+    const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     if (oidcIssuer != null) {
     if (oidcIssuer != null) {
       logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
       logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
 
@@ -719,16 +719,26 @@ class PassportService implements S2sMessageHandlable {
 
 
   /**
   /**
    * Sanitize issuer Host / URL to match specified format
    * Sanitize issuer Host / URL to match specified format
-   * Acceptable format : eg. https://hostname.com
+   * Acceptable formats :
+   * - https://hostname.com/auth/
+   * - domain only (hostname.com)
+   * - Full metadata url (https://hostname.com/auth/v2/.well-known/openid-configuration)
    * @param issuerHost string
    * @param issuerHost string
-   * @returns string URL.origin
+   * @returns string URL/.well-known/openid-configuration
    */
    */
-  getOIDCIssuerHostName(issuerHost) {
+  getOIDCMetadataURL(issuerHost: string) : string {
     const protocol = 'https://';
     const protocol = 'https://';
     const pattern = /^https?:\/\//i;
     const pattern = /^https?:\/\//i;
+    const metadataPath = '/.well-known/openid-configuration';
+    // If URL is full path with .well-known/openid-configuration
+    if (issuerHost.endsWith(metadataPath)) {
+      return issuerHost;
+    }
     // Set protocol if not available on url
     // Set protocol if not available on url
     const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
     const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
-    return new URL(absUrl).origin;
+    const url = new URL(absUrl).href;
+    // Remove trailing slash if exists
+    return `${url.replace(/\/+$/, '')}${metadataPath}`;
   }
   }
 
 
   /**
   /**
@@ -736,17 +746,17 @@ class PassportService implements S2sMessageHandlable {
  * Check and initialize connection to OIDC issuer host
  * Check and initialize connection to OIDC issuer host
  * Prevent request timeout error on app init
  * Prevent request timeout error on app init
  *
  *
- * @param issuerHost
+ * @param issuerHost string
  * @returns boolean
  * @returns boolean
  */
  */
-  async isOidcHostReachable(issuerHost) {
+  async isOidcHostReachable(issuerHost: string): Promise<boolean | undefined> {
     try {
     try {
-      const hostname = this.getOIDCIssuerHostName(issuerHost);
+      const metadataUrl = this.getOIDCMetadataURL(issuerHost);
       const client = require('axios').default;
       const client = require('axios').default;
       axiosRetry(client, {
       axiosRetry(client, {
         retries: 3,
         retries: 3,
       });
       });
-      const response = await client.get(`${hostname}/.well-known/openid-configuration`);
+      const response = await client.get(metadataUrl);
       // Check for valid OIDC Issuer configuration
       // Check for valid OIDC Issuer configuration
       if (!response.data.issuer) {
       if (!response.data.issuer) {
         logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
         logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
@@ -763,10 +773,10 @@ class PassportService implements S2sMessageHandlable {
    * Get oidcIssuer object
    * Get oidcIssuer object
    * Utilize p-retry package to retry oidcIssuer initialization 3 times
    * Utilize p-retry package to retry oidcIssuer initialization 3 times
    *
    *
-   * @param issuerHost
+   * @param issuerHost string
    * @returns instance of OIDCIssuer
    * @returns instance of OIDCIssuer
    */
    */
-  async getOIDCIssuerInstace(issuerHost) {
+  async getOIDCIssuerInstance(issuerHost: string): Promise<void | OIDCIssuer> {
     const OIDC_TIMEOUT_MULTIPLIER = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:timeoutMultiplier');
     const OIDC_TIMEOUT_MULTIPLIER = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:timeoutMultiplier');
     const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
     const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
     const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
     const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
@@ -775,8 +785,9 @@ class PassportService implements S2sMessageHandlable {
       logger.error('OidcStrategy: setup failed');
       logger.error('OidcStrategy: setup failed');
       return;
       return;
     }
     }
+    const metadataURL = this.getOIDCMetadataURL(issuerHost);
     const oidcIssuer = await pRetry(async() => {
     const oidcIssuer = await pRetry(async() => {
-      return OIDCIssuer.discover(issuerHost);
+      return OIDCIssuer.discover(metadataURL);
     }, {
     }, {
       onFailedAttempt: (error) => {
       onFailedAttempt: (error) => {
         // get current OIDCIssuer timeout options
         // get current OIDCIssuer timeout options

+ 5 - 4
packages/app/src/stores/editor.tsx

@@ -8,7 +8,7 @@ import { IEditorSettings } from '~/interfaces/editor-settings';
 import { SlackChannels } from '~/interfaces/user-trigger-notification';
 import { SlackChannels } from '~/interfaces/user-trigger-notification';
 
 
 import {
 import {
-  useCurrentUser, useDefaultIndentSize, useIsGuestUser, useCurrentPageId,
+  useCurrentUser, useDefaultIndentSize, useIsGuestUser,
 } from './context';
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useSWRxTagsInfo } from './page';
 import { useSWRxTagsInfo } from './page';
@@ -96,14 +96,15 @@ export type IPageTagsForEditorsOption = {
   sync: (tags?: string[]) => void;
   sync: (tags?: string[]) => void;
 }
 }
 
 
-export const usePageTagsForEditors = (): SWRResponse<string[], Error> & IPageTagsForEditorsOption => {
+export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<string[], Error> & IPageTagsForEditorsOption => {
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
   const swrResult = useStaticSWR<string[], Error>('pageTags', undefined);
   const swrResult = useStaticSWR<string[], Error>('pageTags', undefined);
 
 
   return {
   return {
     ...swrResult,
     ...swrResult,
-    sync: (tags): void => {
+    sync: (): void => {
       const { mutate } = swrResult;
       const { mutate } = swrResult;
-      mutate(tags || [], false);
+      mutate(tagsInfoData?.tags || [], false);
     },
     },
   };
   };
 };
 };

+ 8 - 1
packages/app/src/stores/tag.tsx

@@ -2,7 +2,7 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
-import { IResTagsListApiv1 } from '~/interfaces/tag';
+import { IResTagsListApiv1, IResTagsSearchApiv1 } from '~/interfaces/tag';
 
 
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IResTagsListApiv1, Error> => {
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IResTagsListApiv1, Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
@@ -10,3 +10,10 @@ export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IR
     (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: IResTagsListApiv1) => result),
     (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: IResTagsListApiv1) => result),
   );
   );
 };
 };
+
+export const useSWRxTagsSearch = (query: string): SWRResponse<IResTagsSearchApiv1, Error> => {
+  return useSWRImmutable(
+    ['/tags.search', query],
+    (endpoint, query) => apiGet(endpoint, { q: query }).then((result: IResTagsSearchApiv1) => result),
+  );
+};

+ 1 - 1
packages/app/src/styles/_subnav.scss

@@ -58,7 +58,7 @@
   .total-likes,
   .total-likes,
   .total-bookmarks {
   .total-bookmarks {
     display: flex;
     display: flex;
-    align-items: end;
+    align-items: flex-end;
     padding-right: 8px;
     padding-right: 8px;
     padding-left: 6px;
     padding-left: 6px;
     font-size: 14px;
     font-size: 14px;

+ 0 - 50
packages/app/src/utils/locale-utils.ts

@@ -1,50 +0,0 @@
-import fs from 'fs';
-
-import { resolveFromRoot } from '~/utils/project-dir-utils';
-
-const MIGRATE_LOCALE_MAP = {
-  en: 'en_US',
-  ja: 'ja_JP',
-};
-
-/**
- * List locales dirents
- */
-function listLocaleDirents() {
-  const allDirents = fs.readdirSync(resolveFromRoot('./resource/locales'), { withFileTypes: true });
-  return allDirents
-    .filter(dirent => dirent.isDirectory());
-}
-
-/**
- * List locales aliases
- */
-function listLocaleMetadatas() {
-  return listLocaleDirents()
-    .map(dir => dir.name)
-    .map(localeDirName => require(`../../resource/locales/${localeDirName}/meta.json`));
-}
-
-/**
- * List locales IDs (=subdir names)
- */
-function listLocaleIds() {
-  return listLocaleMetadatas()
-    .map(meta => meta.id);
-}
-
-function migrateDeprecatedLocaleId(localeId) {
-  const toValue = MIGRATE_LOCALE_MAP[localeId];
-
-  if (toValue != null) {
-    return toValue;
-  }
-
-  return localeId;
-}
-
-module.exports = {
-  listLocaleMetadatas,
-  listLocaleIds,
-  migrateDeprecatedLocaleId,
-};