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

Merge pull request #6581 from weseek/feat/104583-render-shared-page

feat: Render shared page
Yuki Takei 3 лет назад
Родитель
Сommit
f87e023601

+ 50 - 0
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -0,0 +1,50 @@
+import React, { ReactNode } from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { GrowiNavbar } from '../Navbar/GrowiNavbar';
+
+import { RawLayout } from './RawLayout';
+
+const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
+const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
+const ShortcutsModal = dynamic(() => import('../ShortcutsModal'), { ssr: false });
+const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
+
+// Fab
+const Fab = dynamic(() => import('../Fab'), { ssr: false });
+
+
+type Props = {
+  title: string
+  className?: string,
+  expandContainer?: boolean,
+  children?: ReactNode
+}
+
+export const ShareLinkLayout = ({
+  children, title, className, expandContainer,
+}: Props): JSX.Element => {
+
+  const myClassName = `${className ?? ''} ${expandContainer ? 'growi-layout-fluid' : ''}`;
+
+  return (
+    <RawLayout title={title} className={myClassName}>
+      <GrowiNavbar />
+
+      <div className="page-wrapper d-flex d-print-block">
+        <div className="flex-fill mw-0">
+          {children}
+        </div>
+      </div>
+
+      <GrowiNavbarBottom />
+
+      <Fab />
+
+      <ShortcutsModal />
+      <PageCreateModal />
+      <SystemVersion showShortcutsButton />
+    </RawLayout>
+  );
+};

+ 1 - 0
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -179,6 +179,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: isNotFound } = useIsNotFound();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
 
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();

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

@@ -10,7 +10,7 @@ import { HtmlElementNode } from 'rehype-toc';
 
 
 // import { getOptionsToSave } from '~/client/util/editor';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
-  useIsGuestUser, useCurrentPageTocNode,
+  useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -200,7 +200,8 @@ export const Page = (props) => {
     tocRef.current = toc;
     tocRef.current = toc;
   }, []);
   }, []);
 
 
-  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();

+ 0 - 52
packages/app/src/components/Page/ShareLinkAlert.jsx

@@ -1,52 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-const ShareLinkAlert = () => {
-  const { t } = useTranslation();
-
-  const shareContent = document.getElementById('is-shared-page');
-  const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
-  const createdAt = shareContent.getAttribute('data-share-link-created-at');
-
-  function generateRatio() {
-    const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
-    const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
-    return remainingTime / wholeTime;
-  }
-
-  let ratio = 1;
-
-  if (expiredAt !== '') {
-    ratio = generateRatio();
-  }
-
-  function specifyColor() {
-    let color;
-    if (ratio >= 0.75) {
-      color = 'success';
-    }
-    else if (ratio < 0.75 && ratio >= 0.5) {
-      color = 'info';
-    }
-    else if (ratio < 0.5 && ratio >= 0.25) {
-      color = 'warning';
-    }
-    else {
-      color = 'danger';
-    }
-    return color;
-  }
-
-  return (
-    <p className={`alert alert-${specifyColor()} py-3 px-4 d-edit-none`}>
-      <i className="icon-fw icon-link"></i>
-      {(expiredAt === '' ? <span>{t('page_page.notice.no_deadline')}</span>
-      // eslint-disable-next-line react/no-danger
-        : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
-      )}
-    </p>
-  );
-};
-
-export default ShareLinkAlert;

+ 52 - 0
packages/app/src/components/Page/ShareLinkAlert.tsx

@@ -0,0 +1,52 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+const generateRatio = (expiredAt: Date, createdAt: Date): number => {
+  const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+  const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
+  return remainingTime / wholeTime;
+};
+
+const getAlertColor = (ratio: number): string => {
+  let color: string;
+
+  if (ratio >= 0.75) {
+    color = 'success';
+  }
+  else if (ratio < 0.75 && ratio >= 0.5) {
+    color = 'info';
+  }
+  else if (ratio < 0.5 && ratio >= 0.25) {
+    color = 'warning';
+  }
+  else {
+    color = 'danger';
+  }
+  return color;
+};
+
+type Props = {
+  createdAt: Date,
+  expiredAt?: Date,
+}
+
+const ShareLinkAlert: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { expiredAt, createdAt } = props;
+
+  const ratio = expiredAt != null ? generateRatio(expiredAt, createdAt) : 1;
+  const alertColor = getAlertColor(ratio);
+
+  return (
+    <p className={`alert alert-${alertColor} my-3 px-4 d-edit-none`}>
+      <i className="icon-fw icon-link"></i>
+      {(expiredAt === null ? <span>{t('page_page.notice.no_deadline')}</span>
+      // eslint-disable-next-line react/no-danger
+        : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+      )}
+    </p>
+  );
+};
+
+export default ShareLinkAlert;

+ 5 - 9
packages/app/src/components/ShareLink/ShareLinkForm.tsx

@@ -1,7 +1,9 @@
 import React, { FC, useState, useCallback } from 'react';
 import React, { FC, useState, useCallback } from 'react';
 
 
 import { isInteger } from 'core-js/fn/number';
 import { isInteger } from 'core-js/fn/number';
-import { format, parse } from 'date-fns';
+import {
+  format, parse, addDays, set,
+} from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -56,8 +58,6 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
   }, []);
   }, []);
 
 
   const generateExpired = useCallback(() => {
   const generateExpired = useCallback(() => {
-    let expiredAt;
-
     if (expirationType === ExpirationType.UNLIMITED) {
     if (expirationType === ExpirationType.UNLIMITED) {
       return null;
       return null;
     }
     }
@@ -66,16 +66,12 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
       if (!isInteger(Number(numberOfDays))) {
       if (!isInteger(Number(numberOfDays))) {
         throw new Error(t('share_links.Invalid_Number_of_Date'));
         throw new Error(t('share_links.Invalid_Number_of_Date'));
       }
       }
-      const date = new Date();
-      date.setDate(date.getDate() + Number(numberOfDays));
-      expiredAt = date;
+      return addDays(new Date(), numberOfDays);
     }
     }
 
 
     if (expirationType === ExpirationType.CUSTOM) {
     if (expirationType === ExpirationType.CUSTOM) {
-      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm", new Date());
+      return set(customExpirationDate, { hours: customExpirationTime.getHours(), minutes: customExpirationTime.getMinutes() });
     }
     }
-
-    return expiredAt;
   }, [t, customExpirationTime, customExpirationDate, expirationType, numberOfDays]);
   }, [t, customExpirationTime, customExpirationDate, expirationType, numberOfDays]);
 
 
   const closeForm = useCallback(() => {
   const closeForm = useCallback(() => {

+ 11 - 0
packages/app/src/interfaces/share-link.ts

@@ -1,4 +1,15 @@
+import { IPageHasId, HasObjectId } from '@growi/core';
+
 // Todo: specify more detailed Type
 // Todo: specify more detailed Type
 export type IResShareLinkList = {
 export type IResShareLinkList = {
   shareLinksResult: any[],
   shareLinksResult: any[],
 };
 };
+
+export type IShareLink = {
+  relatedPage: IPageHasId,
+  createdAt: Date,
+  expiredAt?: Date,
+  description: string,
+};
+
+export type IShareLinkHasId = IShareLink & HasObjectId;

+ 169 - 0
packages/app/src/pages/share/[[...path]].page.tsx

@@ -0,0 +1,169 @@
+import React from 'react';
+
+import { IUser, IUserHasId } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
+import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
+import { Page } from '~/components/Page';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { RendererConfig } from '~/interfaces/services/renderer';
+import { IShareLinkHasId } from '~/interfaces/share-link';
+import {
+  useCurrentUser, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useRendererConfig,
+  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
+} from '~/stores/context';
+
+import {
+  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
+} from '../utils/commons';
+
+const ShareLinkAlert = dynamic(() => import('~/components/Page/ShareLinkAlert'), { ssr: false });
+const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr: false });
+
+type Props = CommonProps & {
+  shareLink?: IShareLinkHasId,
+  isExpired: boolean,
+  currentUser: IUser,
+  disableLinkSharing: boolean,
+  isSearchServiceConfigured: boolean,
+  isSearchServiceReachable: boolean,
+  isSearchScopeChildrenAsDefault: boolean,
+  rendererConfig: RendererConfig,
+};
+
+const SharedPage: NextPage<Props> = (props: Props) => {
+  useShareLinkId(props.shareLink?._id);
+  useCurrentPageId(props.shareLink?.relatedPage._id);
+  useCurrentPagePath(props.shareLink?.relatedPage.path);
+  useCurrentUser(props.currentUser);
+  useCurrentPathname(props.currentPathname);
+  useRendererConfig(props.rendererConfig);
+  useIsSearchServiceConfigured(props.isSearchServiceConfigured);
+  useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  const isNotFound = props.shareLink == null || props.shareLink.relatedPage == null || props.shareLink.relatedPage.isEmpty;
+  const isShowSharedPage = !props.disableLinkSharing && !isNotFound && !props.isExpired;
+
+  return (
+    <ShareLinkLayout title={useCustomTitle(props, 'GROWI')} expandContainer={props.isContainerFluid}>
+      <div className="h-100 d-flex flex-column justify-content-between">
+        <header className="py-0 position-relative">
+          {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
+        </header>
+
+        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+
+        <div className="flex-grow-1">
+          <div id="content-main" className="content-main grw-container-convertible">
+            { props.disableLinkSharing && (
+              <div className="mt-4">
+                <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
+              </div>
+            )}
+
+            { (isNotFound && !props.disableLinkSharing) && (
+              <div className="container-lg">
+                <h2 className="text-muted mt-4">
+                  <i className="icon-ban" aria-hidden="true" />
+                  <span> Page is not found</span>
+                </h2>
+              </div>
+            )}
+
+            { (props.isExpired && !props.disableLinkSharing) && (
+              <div className="container-lg">
+                <h2 className="text-muted mt-4">
+                  <i className="icon-ban" aria-hidden="true" />
+                  <span> Page is expired</span>
+                </h2>
+              </div>
+            )}
+
+            {(isShowSharedPage && props.shareLink != null) && (
+              <>
+                <ShareLinkAlert expiredAt={props.shareLink.expiredAt} createdAt={props.shareLink.createdAt} />
+                <Page />
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+    </ShareLinkLayout>
+  );
+};
+
+function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+
+  props.disableLinkSharing = crowi.configManager.getConfig('crowi', 'security:disableLinkSharing');
+
+  props.isSearchServiceConfigured = crowi.searchService.isConfigured;
+  props.isSearchServiceReachable = crowi.searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
+}
+
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user, crowi } = req;
+  const result = await getServerSideCommonProps(context);
+
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+  const props: Props = result.props as Props;
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  const { linkId } = req.params;
+  try {
+    const ShareLinkModel = crowi.model('ShareLink');
+    const shareLink = await ShareLinkModel.findOne({ _id: linkId }).populate('relatedPage');
+    if (shareLink != null) {
+      props.isExpired = shareLink.isExpired();
+      props.shareLink = shareLink.toObject();
+    }
+  }
+  catch (err) {
+    //
+  }
+
+  injectServerConfigurations(context, props);
+  // await injectUserUISettings(context, props);
+  await injectNextI18NextConfigurations(context, props);
+
+  return {
+    props,
+  };
+};
+
+export default SharedPage;

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

@@ -244,7 +244,7 @@ module.exports = function(crowi, app) {
     .use(userActivation.tokenErrorHandlerMiddeware));
     .use(userActivation.tokenErrorHandlerMiddeware));
   app.post('/user-activation/register', applicationInstalled, csrfProtection, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
   app.post('/user-activation/register', applicationInstalled, csrfProtection, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
 
-  app.get('/share/:linkId', page.showSharedPage);
+  app.get('/share/:linkId', next.delegateToNext);
 
 
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));