1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-22 22:59:44 +02:00

feat: Version 2

Closes #627, closes #1047
This commit is contained in:
Maksim Eltyshev 2025-05-10 02:09:06 +02:00
parent ad7fb51cfa
commit 2ee1166747
1557 changed files with 76832 additions and 47042 deletions

View file

@ -0,0 +1,69 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../lib/custom-ui';
import entryActions from '../../../entry-actions';
import { AttachmentTypes } from '../../../constants/Enums';
import styles from './AddAttachmentStep.module.scss';
const AddAttachmentStep = React.memo(({ onClose }) => {
const dispatch = useDispatch();
const [t] = useTranslation();
const handleFilesSelect = useCallback(
(files) => {
files.forEach((file) => {
dispatch(
entryActions.createAttachmentInCurrentCard({
file,
type: AttachmentTypes.FILE,
name: file.name,
}),
);
});
onClose();
},
[onClose, dispatch],
);
return (
<>
<Popup.Header>
{t('common.addAttachment', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<FilePicker multiple onSelect={handleFilesSelect}>
<Menu.Item className={styles.menuItem}>
{t('common.fromComputer', {
context: 'title',
})}
</Menu.Item>
</FilePicker>
</Menu>
<hr className={styles.divider} />
<div className={styles.tip}>
{t('common.pressPasteShortcutToAddAttachmentFromClipboard')}
</div>
</Popup.Content>
</>
);
});
AddAttachmentStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default AddAttachmentStep;

View file

@ -0,0 +1,27 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.divider {
background: #eee;
border: 0;
height: 1px;
margin-bottom: 8px;
}
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
.tip {
opacity: 0.5;
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import AddAttachmentStep from './AddAttachmentStep';
export default AddAttachmentStep;

View file

@ -0,0 +1,120 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Gallery } from 'react-photoswipe-gallery';
import { Button } from 'semantic-ui-react';
import { useToggle } from '../../../lib/hooks';
import selectors from '../../../selectors';
import { ClosableContext } from '../../../contexts';
import Item from './Item';
import styles from './Attachments.module.scss';
const INITIALLY_VISIBLE = 4;
const Attachments = React.memo(({ hideImagesWhenNotAllVisible }) => {
const attachments = useSelector(selectors.selectAttachmentsForCurrentCard);
const [t] = useTranslation();
const [isAllVisible, toggleAllVisible] = useToggle();
const [activateClosable, deactivateClosable] = useContext(ClosableContext);
const handleBeforeGalleryOpen = useCallback(
(gallery) => {
activateClosable();
gallery.on('destroy', () => {
deactivateClosable();
});
},
[activateClosable, deactivateClosable],
);
const handleToggleAllVisibleClick = useCallback(() => {
toggleAllVisible();
}, [toggleAllVisible]);
let visibleTotal = 0;
const itemsNode = attachments.map((attachment) => {
let isVisible = false;
if (isAllVisible || visibleTotal < INITIALLY_VISIBLE) {
if (
isAllVisible ||
!hideImagesWhenNotAllVisible ||
!attachment.data ||
!attachment.data.image
) {
visibleTotal += 1;
isVisible = true;
}
}
return <Item key={attachment.id} id={attachment.id} isVisible={isVisible} />;
});
const hiddenTotal = attachments.length - visibleTotal;
return (
<>
<Gallery
withCaption
withDownloadButton
options={{
wheelToZoom: true,
showHideAnimationType: 'none',
closeTitle: '',
zoomTitle: '',
arrowPrevTitle: '',
arrowNextTitle: '',
errorMsg: '',
paddingFn: (viewportSize) => {
const paddingX = viewportSize.x / 20;
const paddingY = viewportSize.y / 20;
return {
top: paddingX,
bottom: paddingX,
left: paddingY,
right: paddingY,
};
},
}}
onBeforeOpen={handleBeforeGalleryOpen}
>
{itemsNode}
</Gallery>
{(isAllVisible ? attachments.length > hiddenTotal : hiddenTotal > 0) && (
<Button
fluid
content={
isAllVisible
? t('action.showFewerAttachments')
: t('action.showAllAttachments', {
hidden: hiddenTotal,
})
}
className={styles.toggleButton}
onClick={handleToggleAllVisibleClick}
/>
)}
</>
);
});
Attachments.propTypes = {
hideImagesWhenNotAllVisible: PropTypes.bool,
};
Attachments.defaultProps = {
hideImagesWhenNotAllVisible: false,
};
export default Attachments;

View file

@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.toggleButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 11px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
}

View file

@ -0,0 +1,111 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Frame from 'react-frame-component';
import { Loader } from 'semantic-ui-react';
import syntaxHighlighter from '../../../lib/syntax-highlighter';
import Markdown from '../../common/Markdown';
import styles from './ContentViewer.module.scss';
const Languages = {
PLAINTEXT: 'plaintext',
MARKDOWN: 'markdown',
};
const ContentViewer = React.memo(({ src, filename, className }) => {
const [content, setContent] = useState(null);
const frameStyles = useMemo(
() => [
...Array.from(document.styleSheets).flatMap((styleSheet) =>
Array.from(styleSheet.cssRules).map((cssRule) => cssRule.cssText),
),
'body{background:rgb(248,248,248);min-width:fit-content;overflow-x:visible}',
'.frame-content{padding:40px}',
'.frame-content>pre{margin:0}',
'.hljs{padding:0}',
'::-webkit-scrollbar{height:10px}',
],
[],
);
const languages = useMemo(
() => syntaxHighlighter.detectLanguagesByFilename(filename),
[filename],
);
useEffect(() => {
async function fetchFile() {
await syntaxHighlighter.loadLanguages(languages);
let response;
try {
response = await fetch(src, {
credentials: 'include',
});
} catch {
return;
}
const text = await response.text();
setContent(text);
}
fetchFile();
}, [src, languages]);
if (content === null) {
return <Loader active size="big" />;
}
let contentNode;
if (languages.includes(Languages.PLAINTEXT)) {
contentNode = (
<pre>
<code>{content}</code>
</pre>
);
} else if (languages.includes(Languages.MARKDOWN)) {
contentNode = <Markdown>{content}</Markdown>;
} else {
const hljsResult = syntaxHighlighter.highlight(content, languages);
contentNode = (
<pre>
<code
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: hljsResult.value }}
className={`hljs language-${hljsResult.language}`}
/>
</pre>
);
}
return (
<Frame
head={<style>{frameStyles.join('')}</style>}
className={classNames(styles.wrapper, className)}
>
{contentNode}
</Frame>
);
});
ContentViewer.propTypes = {
src: PropTypes.string.isRequired,
filename: PropTypes.string.isRequired,
className: PropTypes.string,
};
ContentViewer.defaultProps = {
className: undefined,
};
export default ContentViewer;

View file

@ -0,0 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
background: #fff;
border: 0;
}
}

View file

@ -0,0 +1,129 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import { dequal } from 'dequal';
import React, { useCallback, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef, useSteps } from '../../../hooks';
import ConfirmationStep from '../../common/ConfirmationStep';
import styles from './EditStep.module.scss';
const StepTypes = {
DELETE: 'DELETE',
};
const EditStep = React.memo(({ attachmentId, onClose }) => {
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
const attachment = useSelector((state) => selectAttachmentById(state, attachmentId));
const dispatch = useDispatch();
const [t] = useTranslation();
const defaultData = useMemo(
() => ({
name: attachment.name,
}),
[attachment.name],
);
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
}));
const [step, openStep, handleBack] = useSteps();
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameFieldRef.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
dispatch(entryActions.updateAttachment(attachmentId, cleanData));
}
onClose();
}, [attachmentId, onClose, dispatch, defaultData, data, nameFieldRef]);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteAttachment(attachmentId));
}, [attachmentId, dispatch]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
useEffect(() => {
nameFieldRef.current.focus({
preventScroll: true,
});
}, [nameFieldRef]);
if (step && step.type === StepTypes.DELETE) {
return (
<ConfirmationStep
title="common.deleteAttachment"
content="common.areYouSureYouWantToDeleteThisAttachment"
buttonContent="action.deleteAttachment"
onConfirm={handleDeleteConfirm}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.editAttachment', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={handleNameFieldRef}
name="name"
value={data.name}
maxLength={128}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
<Button
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</Popup.Content>
</>
);
});
EditStep.propTypes = {
attachmentId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
export default EditStep;

View file

@ -0,0 +1,24 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View file

@ -0,0 +1,30 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { Icon } from 'semantic-ui-react';
import styles from './Favicon.module.scss';
const Favicon = React.memo(({ url }) => {
const [isImageError, setIsImageError] = useState(false);
const handleImageError = useCallback(() => {
setIsImageError(true);
}, []);
return isImageError ? (
<Icon fitted name="linkify" className={styles.fallbackIcon} />
) : (
<img src={url} onError={handleImageError} /> // eslint-disable-line jsx-a11y/alt-text
);
});
Favicon.propTypes = {
url: PropTypes.string.isRequired,
};
export default Favicon;

View file

@ -0,0 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.fallbackIcon {
color: #5e6c84;
font-size: 18px;
}
}

View file

@ -0,0 +1,129 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Item as GalleryItem } from 'react-photoswipe-gallery';
import selectors from '../../../selectors';
import Encodings from '../../../constants/Encodings';
import { AttachmentTypes } from '../../../constants/Enums';
import ItemContent from './ItemContent';
import ContentViewer from './ContentViewer';
import styles from './Item.module.scss';
const Item = React.memo(({ id, isVisible }) => {
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
const attachment = useSelector((state) => selectAttachmentById(state, id));
const [t] = useTranslation();
if (!attachment.isPersisted) {
return <ItemContent id={id} />;
}
let galleryItemProps;
if (attachment.type === AttachmentTypes.FILE) {
if (attachment.data.image) {
galleryItemProps = attachment.data.image;
} else {
let content;
switch (attachment.data.mimeType) {
case 'application/pdf':
content = (
// eslint-disable-next-line jsx-a11y/alt-text
<object
data={attachment.data.url}
type={attachment.data.mimeType}
className={classNames(styles.content, styles.contentViewer)}
/>
);
break;
case 'audio/mpeg':
case 'audio/wav':
case 'audio/ogg':
case 'audio/opus':
case 'audio/mp4':
case 'audio/x-aac':
content = (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls src={attachment.data.url} className={styles.content} />
);
break;
case 'video/mp4':
case 'video/ogg':
case 'video/webm':
content = (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video controls src={attachment.data.url} className={styles.content} />
);
break;
default:
if (attachment.data.encoding === Encodings.UTF8) {
content = (
<ContentViewer
src={attachment.data.url}
filename={attachment.data.filename}
className={classNames(styles.content, styles.contentViewer)}
/>
);
} else {
content = (
<span className={classNames(styles.content, styles.contentError)}>
{t('common.thereIsNoPreviewAvailableForThisAttachment')}
</span>
);
}
}
galleryItemProps = {
content,
};
}
} else if (attachment.type === AttachmentTypes.LINK) {
galleryItemProps = {
content: (
<span className={classNames(styles.content, styles.contentError)}>
{t('common.thereIsNoPreviewAvailableForThisAttachment')}
</span>
),
};
}
return (
<GalleryItem
{...galleryItemProps} // eslint-disable-line react/jsx-props-no-spreading
original={attachment.data.url}
caption={attachment.name}
>
{({ ref, open }) =>
isVisible ? (
<ItemContent
ref={ref}
id={id}
onOpen={attachment.type === AttachmentTypes.FILE ? open : undefined}
/>
) : (
<span ref={ref} />
)
}
</GalleryItem>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
isVisible: PropTypes.bool.isRequired,
};
export default Item;

View file

@ -0,0 +1,28 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.content {
bottom: 0;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0;
}
.contentViewer {
height: 90%;
width: MIN(90%, 1120px); // https://github.com/sass/node-sass/issues/2815
}
.contentError {
color: #fff;
font-size: 20px;
font-weight: bold;
height: 20px;
width: 470px;
}
}

View file

@ -0,0 +1,159 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Label, Loader } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { usePopupInClosableContext } from '../../../hooks';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { AttachmentTypes, BoardMembershipRoles } from '../../../constants/Enums';
import EditStep from './EditStep';
import Favicon from './Favicon';
import TimeAgo from '../../common/TimeAgo';
import styles from './ItemContent.module.scss';
const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const attachment = useSelector((state) => selectAttachmentById(state, id));
const isCover = useSelector(
(state) => id === selectors.selectCurrentCard(state).coverAttachmentId,
);
const canEdit = useSelector((state) => {
const { listId } = selectors.selectCurrentCard(state);
const list = selectListById(state, listId);
if (isListArchiveOrTrash(list)) {
return false;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const dispatch = useDispatch();
const [t] = useTranslation();
const handleClick = useCallback(() => {
if (onOpen) {
onOpen();
} else {
window.open(attachment.data.url, '_blank');
}
}, [onOpen, attachment.data]);
const handleToggleCoverClick = useCallback(
(event) => {
event.stopPropagation();
dispatch(
entryActions.updateCurrentCard({
coverAttachmentId: isCover ? null : id,
}),
);
},
[id, isCover, dispatch],
);
const EditPopup = usePopupInClosableContext(EditStep);
if (!attachment.isPersisted) {
return (
<div className={classNames(styles.wrapper, styles.wrapperSubmitting)}>
<Loader inverted />
</div>
);
}
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<div ref={ref} className={styles.wrapper} onClick={handleClick}>
<div
className={styles.thumbnail}
style={{
background:
attachment.type === AttachmentTypes.FILE &&
attachment.data.image &&
`url("${attachment.data.thumbnailUrls.outside360}") center / cover`,
}}
>
{attachment.type === AttachmentTypes.FILE &&
(attachment.data.image ? (
isCover && (
<Label
corner="left"
size="mini"
icon={{
name: 'checkmark',
color: 'grey',
inverted: true,
}}
className={styles.thumbnailLabel}
/>
)
) : (
<span className={styles.thumbnailExtension}>{attachment.data.extension || '-'}</span>
))}
{attachment.type === AttachmentTypes.LINK && <Favicon url={attachment.data.faviconUrl} />}
</div>
<div className={styles.details}>
<span className={styles.name}>{attachment.name}</span>
<span className={styles.information}>
<TimeAgo date={attachment.createdAt} />
</span>
{attachment.type === AttachmentTypes.FILE && attachment.data.image && canEdit && (
<span className={styles.options}>
<button type="button" className={styles.option} onClick={handleToggleCoverClick}>
<Icon
name="window maximize outline"
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
context: 'title',
})}
</span>
</button>
</span>
)}
</div>
{canEdit && (
<EditPopup attachmentId={id}>
<Button className={styles.editButton}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
</div>
);
});
ItemContent.propTypes = {
id: PropTypes.string.isRequired,
onOpen: PropTypes.func,
};
ItemContent.defaultProps = {
onOpen: undefined,
};
export default React.memo(ItemContent);

View file

@ -0,0 +1,126 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.details {
box-sizing: border-box;
padding: 6px 32px 6px 128px;
margin: 0;
min-height: 80px;
}
.editButton {
background: transparent;
box-shadow: none;
line-height: 28px;
margin: 0;
min-height: auto;
opacity: 0;
padding: 0;
position: absolute;
right: 4px;
top: 4px;
width: 28px;
&:hover {
background: rgba(9, 30, 66, 0.08);
}
}
.information {
display: block;
color: #6b808c;
line-height: 20px;
margin-bottom: 6px;
}
.name {
color: #17394d;
font-size: 14px;
font-weight: bold;
line-height: 20px;
word-wrap: break-word;
}
.option {
background: none;
border: none;
color: #6b808c;
cursor: pointer;
outline: none;
padding: 0;
&:hover {
color: #172b4d;
}
}
.optionIcon {
margin-right: 6px;
}
.optionText {
text-decoration: underline;
}
.options {
display: block;
color: #6b808c;
line-height: 20px;
}
.thumbnail {
align-items: center;
background: rgba(9, 30, 66, 0.04);
border-radius: 3px;
display: flex;
height: 80px;
justify-content: center;
margin-top: -40px;
position: absolute;
top: 50%;
width: 112px;
}
.thumbnailExtension {
color: #5e6c84;
display: block;
font-size: 18px;
font-weight: bold;
overflow: hidden;
padding: 0 20px 0 20px;
text-overflow: ellipsis;
text-transform: uppercase;
}
.thumbnailLabel {
border-color: rgba(29, 46, 63, 0.8);
i {
cursor: inherit;
}
}
.wrapper {
cursor: pointer;
margin-bottom: 8px;
min-height: 80px;
position: relative;
&:hover {
.details {
background: rgba(9, 30, 66, 0.04);
}
.editButton {
opacity: 1;
}
}
}
.wrapperSubmitting {
background: rgba(9, 30, 66, 0.04);
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import Attachments from './Attachments';
export default Attachments;