Browse Source

Merge branch 'master' into imprv/load-template-plugins-on-server

Yuki Takei 2 years ago
parent
commit
52b47f8291
40 changed files with 458 additions and 187 deletions
  1. 1 1
      .github/workflows/release.yml
  2. 1 1
      apps/app/package.json
  3. 11 0
      apps/app/public/images/icons/editor/attachment.svg
  4. 1 0
      apps/app/public/static/locales/en_US/commons.json
  5. 6 0
      apps/app/public/static/locales/en_US/translation.json
  6. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  7. 6 0
      apps/app/public/static/locales/ja_JP/translation.json
  8. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  9. 6 0
      apps/app/public/static/locales/zh_CN/translation.json
  10. 11 1
      apps/app/src/client/services/renderer/renderer.tsx
  11. 11 10
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  12. 9 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  13. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  14. 7 57
      apps/app/src/components/PageAttachment.tsx
  15. 65 48
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  16. 1 1
      apps/app/src/components/PageAttachment/PageAttachmentList.tsx
  17. 0 17
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  18. 7 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss
  19. 84 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  20. 1 1
      apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx
  21. 1 6
      apps/app/src/components/User/Username.tsx
  22. 45 0
      apps/app/src/server/routes/apiv3/attachment.js
  23. 44 0
      apps/app/src/services/renderer/remark-plugins/attachment.ts
  24. 31 3
      apps/app/src/stores/attachment.tsx
  25. 46 0
      apps/app/src/stores/modal.tsx
  26. 1 1
      packages/core/package.json
  27. 2 2
      packages/core/src/interfaces/attachment.ts
  28. 1 1
      packages/hackmd/package.json
  29. 1 0
      packages/pluginkit/package.json
  30. 8 3
      packages/presentation/package.json
  31. 5 4
      packages/preset-templates/package.json
  32. 1 1
      packages/preset-themes/package.json
  33. 11 6
      packages/remark-attachment-refs/package.json
  34. 1 1
      packages/remark-drawio/package.json
  35. 1 1
      packages/remark-growi-directive/package.json
  36. 8 3
      packages/remark-lsx/package.json
  37. 1 1
      packages/slack/package.json
  38. 7 3
      packages/ui/package.json
  39. 0 2
      packages/ui/src/components/Attachment.tsx
  40. 11 10
      yarn.lock

+ 1 - 1
.github/workflows/release.yml

@@ -98,7 +98,7 @@ jobs:
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        turbo run bump-versions:rc
+        yarn bump-versions:rc
         yarn upgrade --scope=@growi
         yarn upgrade --scope=@growi
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",

+ 11 - 0
apps/app/public/images/icons/editor/attachment.svg

@@ -0,0 +1,11 @@
+<svg id="group_5327" data-name="group 5327" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="28.093" viewBox="0 0 24 28.093">
+  <defs>
+    <clipPath id="clip-path">
+      <rect id="rectangle_1922" data-name="rectangle 1922" width="24" height="28.093" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </clipPath>
+  </defs>
+  <g id="group_5319" data-name="group 5319" clip-path="url(#clip-path)">
+    <path id="pass_4850" data-name="pass 4850" d="M20.6,16.976l-.651,1.17a4.292,4.292,0,0,1-.828,1.031V21H13.7v5.619H1.479V1.479H19.123v2a1.932,1.932,0,0,1,.2.094l1.282.714V0H0V28.093H15.18v0h0L20.6,22.48l-.006-.006H20.6ZM15.18,25.957V22.474h3.369Z" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    <path id="pass_4851" data-name="pass 4851" d="M203.477,65.236a.648.648,0,0,1,.509.96l-5.117,9.2a3.483,3.483,0,0,0,1.537,4.427,3.8,3.8,0,0,0,3.11.3,3.293,3.293,0,0,0,1.744-1.212l4.784-8.6-3.846-2.14L201.727,76.2c0,.007-.36.684.2,1a.825.825,0,0,0,.689.1.9.9,0,0,0,.461-.417l3.591-6.454,1.131.629-3.592,6.454a2.176,2.176,0,0,1-1.158,1.008,2.074,2.074,0,0,1-1.752-.19,1.832,1.832,0,0,1-.973-1.509,2.366,2.366,0,0,1,.271-1.248l4.786-8.6a.647.647,0,0,1,.88-.251l4.978,2.77a.647.647,0,0,1,.251.88l-5.1,9.163a4.531,4.531,0,0,1-2.469,1.811,5.062,5.062,0,0,1-4.146-.4,4.767,4.767,0,0,1-2.039-6.188l5.117-9.2a.648.648,0,0,1,.622-.33" transform="translate(-187.572 -62.019)" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+  </g>
+</svg>

+ 1 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -6,6 +6,7 @@
   "Reset": "Reset",
   "Reset": "Reset",
   "Sign out": "Logout",
   "Sign out": "Logout",
   "New": "New",
   "New": "New",
+  "Delete": "Delete",
 
 
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"

+ 6 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -820,5 +820,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "tag name"
       "tag_name": "tag name"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "Delete attachment?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "The attachment could not be found"
   }
   }
 }
 }

+ 1 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -6,6 +6,7 @@
   "Reset": "リセット",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
   "New": "作成",
   "New": "作成",
+  "Delete": "削除",
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"
   },
   },

+ 6 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -853,5 +853,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "タグ名"
       "tag_name": "タグ名"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "アタッチメントを削除しますか?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "アタッチメントが見つかりません"
   }
   }
 }
 }

+ 1 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -6,6 +6,7 @@
   "Reset": "重启",
   "Reset": "重启",
 	"Sign out": "退出",
 	"Sign out": "退出",
   "New": "新建",
   "New": "新建",
+  "Delete": "删除",
 
 
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"

+ 6 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -823,5 +823,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "标签名称"
       "tag_name": "标签名称"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "你想删除一个附件吗?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "没有找到附件"
   }
   }
 }
 }

+ 11 - 1
apps/app/src/client/services/renderer/renderer.tsx

@@ -14,9 +14,9 @@ import math from 'remark-math';
 import deepmerge from 'ts-deepmerge';
 import deepmerge from 'ts-deepmerge';
 import type { Pluggable } from 'unified';
 import type { Pluggable } from 'unified';
 
 
-
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
+import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
@@ -25,6 +25,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
+import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
 import {
@@ -61,6 +62,7 @@ export const generateViewOptions = (
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -77,6 +79,7 @@ export const generateViewOptions = (
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
     )]
@@ -109,6 +112,7 @@ export const generateViewOptions = (
     components.drawio = DrawioViewerWithEditButton;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
     components.table = TableWithEditButton;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -167,6 +171,7 @@ export const generateSimpleViewOptions = (
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -187,6 +192,7 @@ export const generateSimpleViewOptions = (
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
     )]
@@ -211,6 +217,7 @@ export const generateSimpleViewOptions = (
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -244,6 +251,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -260,6 +268,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
@@ -285,6 +294,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {

+ 11 - 10
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -7,9 +7,10 @@ import { IUserGroupHasId } from '~/interfaces/user';
 
 
 type Props = {
 type Props = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
+  parentUserGroup?: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   submitButtonLabel: string;
-  onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
+  onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
 };
 };
 
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,16 +18,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
   } = props;
   } = props;
-
   /*
   /*
    * State
    * State
    */
    */
-  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
-  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
-
+  const [currentName, setName] = useState<string>(userGroup.name);
+  const [currentDescription, setDescription] = useState<string>(userGroup.description);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
   /*
   /*
    * Function
    * Function
    */
    */
@@ -44,10 +43,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     }
     }
   }, [selectedParent, setSelectedParent]);
   }, [selectedParent, setSelectedParent]);
 
 
+  const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
+
   return (
   return (
     <form onSubmit={(e) => {
     <form onSubmit={(e) => {
       e.preventDefault();
       e.preventDefault();
-      onSubmit?.(props.userGroup, {
+      onSubmit(props.userGroup, {
         name: currentName,
         name: currentName,
         description: currentDescription,
         description: currentDescription,
         parent: selectedParent,
         parent: selectedParent,
@@ -103,14 +104,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               id="dropdownMenuButton"
               id="dropdownMenuButton"
               data-toggle="dropdown"
               data-toggle="dropdown"
               className={`
               className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
               `}
               `}
             >
             >
               {selectedParent?.name ?? t('user_group_management.select_parent_group')}
               {selectedParent?.name ?? t('user_group_management.select_parent_group')}
             </button>
             </button>
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               {
               {
-                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                isSelectableParentUserGroups && (
                   <>
                   <>
                     {
                     {
                       selectableParentUserGroups.map(userGroup => (
                       selectableParentUserGroups.map(userGroup => (

+ 9 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -21,9 +21,12 @@ import {
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
 } from '~/stores/user-group';
 } from '~/stores/user-group';
+import loggerFactory from '~/utils/logger';
 
 
 import styles from './UserGroupDetailPage.module.scss';
 import styles from './UserGroupDetailPage.module.scss';
 
 
+const logger = loggerFactory('growi:services:AdminCustomizeContainer');
+
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
 
 
@@ -48,6 +51,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { userGroupId: currentUserGroupId } = props;
   const { userGroupId: currentUserGroupId } = props;
 
 
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
@@ -91,6 +95,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
 
+  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
   /*
   /*
    * Function
    * Function
    */
    */
@@ -135,9 +140,10 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     [t, updateUserGroup],
     [t, updateUserGroup],
   );
   );
 
 
-  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
-    if (typeof userGroupData?.parent === 'string') {
+  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId) => {
+    if (typeof userGroupData.parent === 'string') {
       toastError(t('Something went wrong. Please try again.'));
       toastError(t('Something went wrong. Please try again.'));
+      logger.error('Something went wrong.');
       return;
       return;
     }
     }
 
 
@@ -356,6 +362,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       <div className="mt-4 form-box">
       <div className="mt-4 form-box">
         <UserGroupForm
         <UserGroupForm
           userGroup={currentUserGroup}
           userGroup={currentUserGroup}
+          parentUserGroup={parentUserGroup}
           selectableParentUserGroups={selectableParentUserGroups}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
           onSubmit={onClickSubmitForm}

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -10,6 +10,7 @@ import Sidebar from '../Sidebar';
 import { RawLayout } from './RawLayout';
 import { RawLayout } from './RawLayout';
 
 
 const AlertSiteUrlUndefined = dynamic(() => import('../AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
 const AlertSiteUrlUndefined = dynamic(() => import('../AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
+const DeleteAttachmentModal = dynamic(() => import('../PageAttachment/DeleteAttachmentModal').then(mod => mod.DeleteAttachmentModal), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
 // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
@@ -56,6 +57,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageDeleteModal />
         <PageDeleteModal />
         <PageRenameModal />
         <PageRenameModal />
         <PageAccessoriesModal />
         <PageAccessoriesModal />
+        <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
         <DeleteBookmarkFolderModal />
       </DndProvider>
       </DndProvider>
 
 

+ 7 - 57
apps/app/src/components/PageAttachment.tsx

@@ -6,9 +6,9 @@ import { IAttachmentHasId } from '@growi/core';
 
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
+import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
 
-import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
@@ -19,9 +19,6 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 
 
 const PageAttachment = (): JSX.Element => {
 const PageAttachment = (): JSX.Element => {
 
 
-  const { data: currentPage } = useSWRxCurrentPage();
-  const markdown = currentPage?.revision.body;
-
   // Static SWRs
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
@@ -31,12 +28,12 @@ const PageAttachment = (): JSX.Element => {
 
 
   // States
   // States
   const [pageNumber, setPageNumber] = useState(1);
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachmentHasId) | null>(null);
-  const [deleting, setDeleting] = useState(false);
-  const [deleteError, setDeleteError] = useState('');
 
 
   // SWRs
   // SWRs
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const markdown = currentPage?.revision.body;
 
 
   // Custom hooks
   // Custom hooks
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
@@ -57,29 +54,9 @@ const PageAttachment = (): JSX.Element => {
     setPageNumber(newPageNumber);
     setPageNumber(newPageNumber);
   }, []);
   }, []);
 
 
-  const onAttachmentDeleteClicked = useCallback((attachment) => {
-    setAttachmentToDelete(attachment);
-  }, []);
-
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachmentHasId) => {
-    setDeleting(true);
-
-    try {
-      await remove({ attachment_id: attachment._id });
-
-      setAttachmentToDelete(null);
-      setDeleting(false);
-    }
-    catch {
-      setDeleteError('Something went wrong.');
-      setDeleting(false);
-    }
-  }, [remove]);
-
-  const onToggleHandler = useCallback(() => {
-    setAttachmentToDelete(null);
-    setDeleteError('');
-  }, []);
+  const onAttachmentDeleteClicked = useCallback((attachment: IAttachmentHasId) => {
+    openDeleteAttachmentModal(attachment, remove);
+  }, [openDeleteAttachmentModal, remove]);
 
 
   // Renderers
   // Renderers
   const renderPageAttachmentList = useCallback(() => {
   const renderPageAttachmentList = useCallback(() => {
@@ -101,30 +78,6 @@ const PageAttachment = (): JSX.Element => {
     );
     );
   }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
   }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
 
 
-  const renderDeleteAttachmentModal = useCallback(() => {
-    if (isPageAttachmentDisabled) {
-      return <></>;
-    }
-
-    if (dataAttachments == null || dataAttachments.attachments.length === 0 || attachmentToDelete == null) {
-      return <></>;
-    }
-
-    const isOpen = attachmentToDelete != null;
-
-    return (
-      <DeleteAttachmentModal
-        isOpen={isOpen}
-        toggle={onToggleHandler}
-        attachmentToDelete={attachmentToDelete}
-        deleting={deleting}
-        deleteError={deleteError}
-        onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
-      />
-    );
-  // eslint-disable-next-line max-len
-  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isPageAttachmentDisabled, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
-
   const renderPaginationWrapper = useCallback(() => {
   const renderPaginationWrapper = useCallback(() => {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {
       return <></>;
       return <></>;
@@ -144,9 +97,6 @@ const PageAttachment = (): JSX.Element => {
   return (
   return (
     <div data-testid="page-attachment">
     <div data-testid="page-attachment">
       {renderPageAttachmentList()}
       {renderPageAttachmentList()}
-
-      {renderDeleteAttachmentModal()}
-
       {renderPaginationWrapper()}
       {renderPaginationWrapper()}
     </div>
     </div>
   );
   );

+ 65 - 48
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -1,79 +1,97 @@
-/* eslint-disable react/prop-types */
-import React, { useCallback } from 'react';
+import React, {
+  useCallback, useMemo, useState,
+} from 'react';
 
 
-import { IAttachmentHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
 import {
 import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
+
 import { Username } from '../User/Username';
 import { Username } from '../User/Username';
 
 
 import styles from './DeleteAttachmentModal.module.scss';
 import styles from './DeleteAttachmentModal.module.scss';
 
 
+const logger = loggerFactory('growi:attachmentDelete');
+
+const iconByFormat = (format: string): string => {
+  return format.match(/image\/.+/i) ? 'icon-picture' : 'icon-doc';
+};
 
 
-function iconNameByFormat(format: string): string {
-  if (format.match(/image\/.+/i)) {
-    return 'icon-picture';
-  }
+export const DeleteAttachmentModal: React.FC = () => {
+  const [deleting, setDeleting] = useState<boolean>(false);
+  const [deleteError, setDeleteError] = useState<string>('');
 
 
-  return 'icon-doc';
-}
+  const { t } = useTranslation();
+  const { data: deleteAttachmentModal, close: closeDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const isOpen = deleteAttachmentModal?.isOpened;
+  const attachment = deleteAttachmentModal?.attachment;
+  const remove = deleteAttachmentModal?.remove;
 
 
+  const toggleHandler = useCallback(() => {
+    closeDeleteAttachmentModal();
+    setDeleting(false);
+    setDeleteError('');
+  }, [closeDeleteAttachmentModal]);
 
 
-type Props = {
-  isOpen: boolean,
-  toggle: () => void,
-  attachmentToDelete: IAttachmentHasId | null,
-  deleting: boolean,
-  deleteError: string,
-  onAttachmentDeleteClickedConfirm?: (attachment: IAttachmentHasId) => Promise<void>,
-}
+  const onClickDeleteButtonHandler = useCallback(async() => {
+    if (remove == null || attachment == null) {
+      return;
+    }
 
 
-export const DeleteAttachmentModal = (props: Props): JSX.Element => {
+    setDeleting(true);
 
 
-  const {
-    isOpen, toggle,
-    attachmentToDelete, deleting, deleteError,
-    onAttachmentDeleteClickedConfirm,
-  } = props;
+    try {
+      await remove({ attachment_id: attachment._id });
+      setDeleting(false);
+      closeDeleteAttachmentModal();
+      toastSuccess(`Delete ${attachment.originalName}`);
+    }
+    catch (err) {
+      setDeleting(false);
+      setDeleteError('Attachment could not be deleted.');
+      toastError(err);
+      logger.error(err);
+    }
+  }, [attachment, closeDeleteAttachmentModal, remove]);
 
 
-  const onDeleteConfirm = useCallback(() => {
-    if (attachmentToDelete == null || onAttachmentDeleteClickedConfirm == null) {
+  const attachmentFileFormat = useMemo(() => {
+    if (attachment == null) {
       return;
       return;
     }
     }
-    onAttachmentDeleteClickedConfirm(attachmentToDelete);
-  }, [attachmentToDelete, onAttachmentDeleteClickedConfirm]);
 
 
-  const renderByFileFormat = useCallback((attachment) => {
     const content = (attachment.fileFormat.match(/image\/.+/i))
     const content = (attachment.fileFormat.match(/image\/.+/i))
       // eslint-disable-next-line @next/next/no-img-element
       // eslint-disable-next-line @next/next/no-img-element
       ? <img src={attachment.filePathProxied} alt="deleting image" />
       ? <img src={attachment.filePathProxied} alt="deleting image" />
       : '';
       : '';
 
 
-
     return (
     return (
       <div className="attachment-delete-image">
       <div className="attachment-delete-image">
         <p>
         <p>
-          <i className={iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+          <i className={iconByFormat(attachment.fileFormat)}></i> {attachment.originalName}
         </p>
         </p>
         <p>
         <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>
         </p>
         </p>
         {content}
         {content}
       </div>
       </div>
     );
     );
-  }, []);
-
-  let deletingIndicator = <></>;
-  if (deleting) {
-    deletingIndicator = <div className="speeding-wheel-sm"></div>;
-  }
-  if (deleteError) {
-    deletingIndicator = <span>{deleteError}</span>;
-  }
+  }, [attachment]);
 
 
+  const deletingIndicator = useMemo(() => {
+    if (deleting) {
+      return <div className="speeding-wheel-sm"></div>;
+    }
+    if (deleteError) {
+      return <span>{deleteError}</span>;
+    }
+    return <></>;
+  }, [deleting, deleteError]);
 
 
   return (
   return (
     <Modal
     <Modal
@@ -83,11 +101,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
       aria-labelledby="contained-modal-title-lg"
       aria-labelledby="contained-modal-title-lg"
       fade={false}
       fade={false}
     >
     >
-      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
-        <span id="contained-modal-title-lg">Delete attachment?</span>
+      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">{t('delete_attachment_modal.confirm_delete_attachment')}</span>
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        {renderByFileFormat(attachmentToDelete)}
+        {attachmentFileFormat}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <div className="mr-3 d-inline-block">
         <div className="mr-3 d-inline-block">
@@ -95,12 +113,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
         </div>
         </div>
         <Button
         <Button
           color="danger"
           color="danger"
-          onClick={onDeleteConfirm}
+          onClick={onClickDeleteButtonHandler}
           disabled={deleting}
           disabled={deleting}
-        >Delete!
+        >{t('commons:Delete')}
         </Button>
         </Button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );
-
 };
 };

+ 1 - 1
apps/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 type Props = {
 type Props = {
   attachments: (IAttachmentHasId)[],
   attachments: (IAttachmentHasId)[],
   inUse: { [id: string]: boolean },
   inUse: { [id: string]: boolean },
-  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void,
+  onAttachmentDeleteClicked: (attachment: IAttachmentHasId) => void,
   isUserLoggedIn?: boolean,
   isUserLoggedIn?: boolean,
 }
 }
 
 

+ 0 - 17
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -22,10 +22,6 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
   }
   }
 };
 };
 
 
-const isAttached = (href: string): boolean => {
-  return href.startsWith('/attachment/');
-};
-
 type Props = Omit<LinkProps, 'href'> & {
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   children: React.ReactNode,
   id?: string,
   id?: string,
@@ -64,19 +60,6 @@ export const NextLink = (props: Props): JSX.Element => {
     );
     );
   }
   }
 
 
-  // when href is an attachment file
-  if (isAttached(href)) {
-    const dlhref = href.replace('/attachment/', '/download/');
-    return (
-      <span>
-        <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
-          {children}
-        </a>&nbsp;
-        <a href={dlhref} className="attachment-download"><i className='icon-cloud-download'></i></a>
-      </span>
-    );
-  }
-
   return (
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
       <a href={href} className={className} {...dataAttributes}>{children}</a>
       <a href={href} className={className} {...dataAttributes}>{children}</a>

+ 7 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss

@@ -0,0 +1,7 @@
+.attachment :global {
+  .attachment-icon {
+    flex-shrink: 0;
+    width: 35px;
+    height: 35px;
+  }
+}

+ 84 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react';
+
+import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
+import prettyBytes from 'pretty-bytes';
+
+import { useSWRxAttachment } from '~/stores/attachment';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+
+import styles from './RichAttachment.module.scss';
+
+export const RichAttachment: React.FC<{
+  attachmentId: string,
+  url: string,
+  attachmentName: string
+}> = React.memo(({ attachmentId, url, attachmentName }) => {
+  const { t } = useTranslation();
+  const { data: attachment, remove } = useSWRxAttachment(attachmentId);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+
+  const onClickTrashButtonHandler = useCallback(() => {
+    if (attachment == null) {
+      return;
+    }
+    openDeleteAttachmentModal(attachment, remove);
+  }, [attachment, openDeleteAttachmentModal, remove]);
+
+  if (attachment == null) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  const {
+    filePathProxied,
+    originalName,
+    downloadPathProxied,
+    creator,
+    createdAt,
+    fileSize,
+  } = attachment;
+
+  // Guard here because attachment properties might be deleted in turn when an attachment is removed
+  if (filePathProxied == null
+    || originalName == null
+    || downloadPathProxied == null
+    || creator == null
+    || createdAt == null
+    || fileSize == null
+  ) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  return (
+    <div className={`${styles.attachment} d-inline-block`}>
+      <div className="my-2 p-2 card">
+        <div className="p-1 card-body d-flex align-items-center">
+          <div className='mr-2 px-0 d-flex align-items-center justify-content-center'>
+            <img src='/images/icons/editor/attachment.svg' className="attachment-icon" alt='attachment icon'/>
+          </div>
+          <div className='pl-0'>
+            <div className='d-inline-block'>
+              <a target="_blank" rel="noopener noreferrer" href={filePathProxied}>
+                {attachmentName || originalName}
+              </a>
+              <a className="ml-2 attachment-download" href={downloadPathProxied}>
+                <i className="icon-cloud-download"/>
+              </a>
+              <a className="ml-2 text-danger attachment-delete" onClick={onClickTrashButtonHandler}>
+                <i className="icon-trash"/>
+              </a>
+            </div>
+            <div className='d-flex align-items-center'>
+              <UserPicture user={creator} size="sm"/>
+              <span className='ml-2 text-muted'>
+                {new Date(createdAt).toLocaleString('en-US')}
+              </span>
+              <span className='ml-2 pl-2 border-left text-muted'>{prettyBytes(fileSize)}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+});
+RichAttachment.displayName = 'RichAttachment';

+ 1 - 1
apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -46,7 +46,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
           <i className="icon-note"></i>
           <i className="icon-note"></i>
         </button>
         </button>
       )}
       )}
-      <table className={`${className}`}>
+      <table className={className}>
         {children}
         {children}
       </table>
       </table>
     </div>
     </div>

+ 1 - 6
apps/app/src/components/User/Username.tsx

@@ -3,12 +3,7 @@ import React from 'react';
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
-type UsernameProps = {
- user?: IUser,
-}
-
-export const Username = (props: UsernameProps): JSX.Element => {
-  const { user } = props;
+export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
 
 
   if (user == null) {
   if (user == null) {
     return <span>anyone</span>;
     return <span>anyone</span>;

+ 45 - 0
apps/app/src/server/routes/apiv3/attachment.js

@@ -26,12 +26,57 @@ module.exports = (crowi) => {
   const Attachment = crowi.model('Attachment');
   const Attachment = crowi.model('Attachment');
 
 
   const validator = {
   const validator = {
+    attachment: [
+      query('attachmentId').isMongoId().withMessage('attachmentId is required'),
+    ],
     retrieveAttachments: [
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
   };
   };
+
+  /**
+   * @swagger
+   *
+   *    /attachment:
+   *      get:
+   *        tags: [Attachment]
+   *        description: Get attachment
+   *        responses:
+   *          200:
+   *            description: Return attachment
+   *        parameters:
+   *          - name: attachemnt_id
+   *            in: query
+   *            required: true
+   *            description: attachment id
+   *            schema:
+   *              type: string
+   */
+  router.get('/', accessTokenParser, loginRequired, validator.attachment, apiV3FormValidator, async(req, res) => {
+    try {
+      const attachmentId = req.query.attachmentId;
+
+      const attachment = await Attachment.findById(attachmentId).populate('creator').exec();
+
+      if (attachment == null) {
+        const message = 'Attachment not found';
+        return res.apiv3Err(message, 404);
+      }
+
+      if (attachment.creator != null && attachment.creator instanceof User) {
+        attachment.creator = serializeUserSecurely(attachment.creator);
+      }
+
+      return res.apiv3({ attachment });
+    }
+    catch (err) {
+      logger.error('Attachment retrieval failed', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 44 - 0
apps/app/src/services/renderer/remark-plugins/attachment.ts

@@ -0,0 +1,44 @@
+import path from 'path';
+
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { Plugin } from 'unified';
+import { Node } from 'unist';
+import { visit } from 'unist-util-visit';
+
+const SUPPORTED_ATTRIBUTES = ['attachmentId', 'url', 'attachmentName'];
+
+const isAttachmentLink = (url: string) => {
+  // https://regex101.com/r/9qZhiK/1
+  const attachmentUrlFormat = new RegExp(/^\/(attachment)\/([^/^\n]+)$/);
+  return url.match(attachmentUrlFormat);
+};
+
+const rewriteNode = (node: Node) => {
+  const attachmentId = path.basename(node.url as string);
+  const data = node.data ?? (node.data = {});
+  data.hName = 'attachment';
+  data.hProperties = {
+    attachmentId,
+    url: node.url,
+    attachmentName: (node.children as any)[0]?.value,
+  };
+};
+
+export const remarkPlugin: Plugin = () => {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === 'link') {
+        if (isAttachmentLink(node.url as string)) {
+          rewriteNode(node);
+        }
+      }
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['attachment'],
+  attributes: {
+    attachment: SUPPORTED_ATTRIBUTES,
+  },
+};

+ 31 - 3
apps/app/src/stores/attachment.tsx

@@ -3,7 +3,8 @@ import { useCallback } from 'react';
 import {
 import {
   IAttachmentHasId, Nullable, type SWRResponseWithUtils, withUtils,
   IAttachmentHasId, Nullable, type SWRResponseWithUtils, withUtils,
 } from '@growi/core';
 } from '@growi/core';
-import useSWR from 'swr';
+import { Util } from 'reactstrap';
+import useSWR, { useSWRConfig } from 'swr';
 
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
@@ -14,12 +15,37 @@ type Util = {
 };
 };
 
 
 type IDataAttachmentList = {
 type IDataAttachmentList = {
-  attachments: (IAttachmentHasId)[]
+  attachments: IAttachmentHasId[]
   totalAttachments: number
   totalAttachments: number
   limit: number
   limit: number
 };
 };
 
 
+export const useSWRxAttachment = (attachmentId: string): SWRResponseWithUtils<Util, IAttachmentHasId, Error> => {
+  const swrResponse = useSWR(
+    ['/attachment', attachmentId],
+    useCallback(async([endpoint, attachmentId]) => {
+      const params = { attachmentId };
+      const res = await apiv3Get(endpoint, params);
+      return res.data.attachment;
+    }, []),
+  );
+
+  // Utils
+  const remove = useCallback(async(body: { attachment_id: string }) => {
+    try {
+      await apiPost('/attachments.remove', body);
+      swrResponse.mutate(body.attachment_id);
+    }
+    catch (err) {
+      throw err;
+    }
+  }, [swrResponse]);
+
+  return withUtils<Util, IAttachmentHasId, Error>(swrResponse, { remove });
+};
+
 export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
 export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
+  const { mutate: mutateUseSWRxAttachment } = useSWRConfig();
   const shouldFetch = pageId != null && pageNumber != null;
   const shouldFetch = pageId != null && pageNumber != null;
 
 
   const fetcher = useCallback(async([endpoint, pageId, pageNumber]) => {
   const fetcher = useCallback(async([endpoint, pageId, pageNumber]) => {
@@ -45,11 +71,13 @@ export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: numbe
     try {
     try {
       await apiPost('/attachments.remove', body);
       await apiPost('/attachments.remove', body);
       mutate();
       mutate();
+      // Mutation for rich attachment rendering
+      mutateUseSWRxAttachment(['/attachment', body.attachment_id], body.attachment_id);
     }
     }
     catch (err) {
     catch (err) {
       throw err;
       throw err;
     }
     }
-  }, [swrResponse]);
+  }, [mutateUseSWRxAttachment, swrResponse]);
 
 
   return withUtils<Util, IDataAttachmentList, Error>(swrResponse, { remove });
   return withUtils<Util, IDataAttachmentList, Error>(swrResponse, { remove });
 };
 };

+ 46 - 0
apps/app/src/stores/modal.tsx

@@ -1,5 +1,6 @@
 import { useCallback, useMemo } from 'react';
 import { useCallback, useMemo } from 'react';
 
 
+import type { IAttachmentHasId } from '@growi/core';
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
 
 
 import Linker from '~/client/models/Linker';
 import Linker from '~/client/models/Linker';
@@ -656,6 +657,51 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
   });
   });
 };
 };
 
 
+/**
+ * DeleteAttachmentModal
+ */
+type Remove =
+  (body: {
+    attachment_id: string;
+  }) => Promise<void>
+
+type DeleteAttachmentModalStatus = {
+  isOpened: boolean,
+  attachment?: IAttachmentHasId,
+  remove?: Remove,
+}
+
+type DeleteAttachmentModalUtils = {
+  open(
+    attachment: IAttachmentHasId,
+    remove: Remove,
+  ): void,
+  close(): void,
+}
+
+export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalStatus, Error> & DeleteAttachmentModalUtils => {
+  const initialStatus: DeleteAttachmentModalStatus = {
+    isOpened: false,
+    attachment: undefined,
+    remove: undefined,
+  };
+  const swrResponse = useStaticSWR<DeleteAttachmentModalStatus, Error>('deleteAttachmentModal', undefined, { fallbackData: initialStatus });
+  const { mutate } = swrResponse;
+
+  const open = useCallback((attachment: IAttachmentHasId, remove: Remove) => {
+    mutate({ isOpened: true, attachment, remove });
+  }, [mutate]);
+  const close = useCallback((): void => {
+    mutate({ isOpened: false });
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};
+
 /*
 /*
  * LinkEditModal
  * LinkEditModal
  */
  */

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 2 - 2
packages/core/src/interfaces/attachment.ts

@@ -6,10 +6,10 @@ import type { IUser } from './user';
 export type IAttachment = {
 export type IAttachment = {
   page?: Ref<IPage>,
   page?: Ref<IPage>,
   creator?: Ref<IUser>,
   creator?: Ref<IUser>,
-
+  createdAt: Date,
+  fileSize: number,
   // virtual property
   // virtual property
   filePathProxied: string,
   filePathProxied: string,
-
   fileFormat: string,
   fileFormat: string,
   downloadPathProxied: string,
   downloadPathProxied: string,
   originalName: string,
   originalName: string,

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/hackmd",
   "name": "@growi/hackmd",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "license": "MIT",
   "type": "module",
   "type": "module",

+ 1 - 0
packages/pluginkit/package.json

@@ -16,6 +16,7 @@
     "test": "vitest run --coverage"
     "test": "vitest run --coverage"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@growi/core": "^6.1.5-RC",
     "extensible-custom-error": "^0.0.7"
     "extensible-custom-error": "^0.0.7"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 8 - 3
packages/presentation/package.json

@@ -1,12 +1,17 @@
 {
 {
   "name": "@growi/presentation",
   "name": "@growi/presentation",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "GROWI plugin for presentation",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "license": "MIT",
-  "keywords": ["growi", "growi-plugin"],
+  "keywords": [
+    "growi",
+    "growi-plugin"
+  ],
   "module": "dist/presentation.mjs",
   "module": "dist/presentation.mjs",
   "types": "dist/index.d.ts",
   "types": "dist/index.d.ts",
-  "files": ["dist"],
+  "files": [
+    "dist"
+  ],
   "scripts": {
   "scripts": {
     "build": "vite build",
     "build": "vite build",
     "clean": "npx -y shx rm -rf dist",
     "clean": "npx -y shx rm -rf dist",

+ 5 - 4
packages/preset-templates/package.json

@@ -1,12 +1,11 @@
 {
 {
   "name": "@growi/preset-templates",
   "name": "@growi/preset-templates",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "scripts": {
   "scripts": {
     "test": "vitest run",
     "test": "vitest run",
     "version": "yarn version --no-git-tag-version"
     "version": "yarn version --no-git-tag-version"
   },
   },
-  "dependencies": {
-  },
+  "dependencies": {},
   "devDependencies": {
   "devDependencies": {
     "@growi/pluginkit": "link:../pluginkit"
     "@growi/pluginkit": "link:../pluginkit"
   },
   },
@@ -16,7 +15,9 @@
       "template"
       "template"
     ],
     ],
     "locales": [
     "locales": [
-      "en_US", "ja_JP", "zh_CN"
+      "en_US",
+      "ja_JP",
+      "zh_CN"
     ]
     ]
   }
   }
 }
 }

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "@growi/preset-themes",
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
   "description": "GROWI preset themes",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",
   "module": "dist/libs/preset-themes.mjs",

+ 11 - 6
packages/remark-attachment-refs/package.json

@@ -1,13 +1,18 @@
 {
 {
   "name": "@growi/remark-attachment-refs",
   "name": "@growi/remark-attachment-refs",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "license": "MIT",
-  "keywords": ["growi", "growi-plugin"],
+  "keywords": [
+    "growi",
+    "growi-plugin"
+  ],
   "main": "dist/index.js",
   "main": "dist/index.js",
   "module": "dist/index.mjs",
   "module": "dist/index.mjs",
   "types": "dist/index.d.ts",
   "types": "dist/index.d.ts",
-  "files": ["dist"],
+  "files": [
+    "dist"
+  ],
   "scripts": {
   "scripts": {
     "build": "run-p build:*",
     "build": "run-p build:*",
     "build:server": "vite build -c vite.server.config.ts",
     "build:server": "vite build -c vite.server.config.ts",
@@ -25,11 +30,11 @@
     "version": "yarn version --no-git-tag-version"
     "version": "yarn version --no-git-tag-version"
   },
   },
   "dependencies": {
   "dependencies": {
-    "bunyan": "^1.8.15",
-    "universal-bunyan": "^0.9.2",
     "@growi/core": "link:../core",
     "@growi/core": "link:../core",
     "@growi/remark-growi-directive": "link:../remark-growi-directive",
     "@growi/remark-growi-directive": "link:../remark-growi-directive",
-    "@growi/ui": "link:../ui"
+    "@growi/ui": "link:../ui",
+    "bunyan": "^1.8.15",
+    "universal-bunyan": "^0.9.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/remark-drawio",
   "name": "@growi/remark-drawio",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/remark-growi-directive",
   "name": "@growi/remark-growi-directive",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 8 - 3
packages/remark-lsx/package.json

@@ -1,10 +1,15 @@
 {
 {
   "name": "@growi/remark-lsx",
   "name": "@growi/remark-lsx",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "GROWI plugin to list pages",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "license": "MIT",
-  "keywords": ["growi", "growi-plugin"],
-  "files": ["dist"],
+  "keywords": [
+    "growi",
+    "growi-plugin"
+  ],
+  "files": [
+    "dist"
+  ],
   "scripts": {
   "scripts": {
     "build": "run-p build:*",
     "build": "run-p build:*",
     "build:client": "vite build -c vite.client.config.ts",
     "build:client": "vite build -c vite.client.config.ts",

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slack",
   "name": "@growi/slack",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "module": "dist/index.mjs",
   "module": "dist/index.mjs",

+ 7 - 3
packages/ui/package.json

@@ -1,11 +1,15 @@
 {
 {
   "name": "@growi/ui",
   "name": "@growi/ui",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
-  "keywords": ["growi"],
+  "keywords": [
+    "growi"
+  ],
   "type": "module",
   "type": "module",
-  "files": ["dist"],
+  "files": [
+    "dist"
+  ],
   "scripts": {
   "scripts": {
     "build": "vite build",
     "build": "vite build",
     "clean": "npx -y shx rm -rf dist",
     "clean": "npx -y shx rm -rf dist",

+ 0 - 2
packages/ui/src/components/Attachment.tsx

@@ -1,5 +1,3 @@
-import React from 'react';
-
 import { IAttachmentHasId } from '@growi/core';
 import { IAttachmentHasId } from '@growi/core';
 
 
 import { UserPicture } from './User/UserPicture';
 import { UserPicture } from './User/UserPicture';

+ 11 - 10
yarn.lock

@@ -2312,29 +2312,30 @@
     xdg-basedir "^4.0.0"
     xdg-basedir "^4.0.0"
 
 
 "@growi/core@link:packages/core":
 "@growi/core@link:packages/core":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     bson-objectid "^2.0.4"
     bson-objectid "^2.0.4"
     escape-string-regexp "^4.0.0"
     escape-string-regexp "^4.0.0"
 
 
 "@growi/hackmd@link:packages/hackmd":
 "@growi/hackmd@link:packages/hackmd":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
 
 
 "@growi/pluginkit@link:packages/pluginkit":
 "@growi/pluginkit@link:packages/pluginkit":
   version "0.1.0"
   version "0.1.0"
   dependencies:
   dependencies:
+    "@growi/core" "^6.1.5-RC"
     extensible-custom-error "^0.0.7"
     extensible-custom-error "^0.0.7"
 
 
 "@growi/presentation@link:packages/presentation":
 "@growi/presentation@link:packages/presentation":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/core" "link:packages/core"
 
 
 "@growi/preset-themes@link:packages/preset-themes":
 "@growi/preset-themes@link:packages/preset-themes":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
 
 
 "@growi/remark-attachment-refs@link:packages/remark-attachment-refs":
 "@growi/remark-attachment-refs@link:packages/remark-attachment-refs":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2343,12 +2344,12 @@
     universal-bunyan "^0.9.2"
     universal-bunyan "^0.9.2"
 
 
 "@growi/remark-drawio@link:packages/remark-drawio":
 "@growi/remark-drawio@link:packages/remark-drawio":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     pako "^2.1.0"
     pako "^2.1.0"
 
 
 "@growi/remark-growi-directive@link:packages/remark-growi-directive":
 "@growi/remark-growi-directive@link:packages/remark-growi-directive":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     "@types/mdast" "^3.0.0"
     "@types/mdast" "^3.0.0"
     "@types/unist" "^2.0.0"
     "@types/unist" "^2.0.0"
@@ -2365,7 +2366,7 @@
     uvu "^0.5.0"
     uvu "^0.5.0"
 
 
 "@growi/remark-lsx@link:packages/remark-lsx":
 "@growi/remark-lsx@link:packages/remark-lsx":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2376,7 +2377,7 @@
     swr "^2.0.3"
     swr "^2.0.3"
 
 
 "@growi/slack@link:packages/slack":
 "@growi/slack@link:packages/slack":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     "@slack/oauth" "^2.0.1"
     "@slack/oauth" "^2.0.1"
     axios "^0.24.0"
     axios "^0.24.0"
@@ -2389,7 +2390,7 @@
     url-join "^4.0.0"
     url-join "^4.0.0"
 
 
 "@growi/ui@link:packages/ui":
 "@growi/ui@link:packages/ui":
-  version "6.1.4-RC.0"
+  version "6.1.5-RC.0"
   dependencies:
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/core" "link:packages/core"