mirror of
https://github.com/plankanban/planka.git
synced 2025-07-22 22:59:44 +02:00
parent
ad7fb51cfa
commit
2ee1166747
1557 changed files with 76832 additions and 47042 deletions
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
120
client/src/components/attachments/Attachments/Attachments.jsx
Normal file
120
client/src/components/attachments/Attachments/Attachments.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
111
client/src/components/attachments/Attachments/ContentViewer.jsx
Normal file
111
client/src/components/attachments/Attachments/ContentViewer.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
129
client/src/components/attachments/Attachments/EditStep.jsx
Executable file
129
client/src/components/attachments/Attachments/EditStep.jsx
Executable 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
30
client/src/components/attachments/Attachments/Favicon.jsx
Normal file
30
client/src/components/attachments/Attachments/Favicon.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
129
client/src/components/attachments/Attachments/Item.jsx
Normal file
129
client/src/components/attachments/Attachments/Item.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
159
client/src/components/attachments/Attachments/ItemContent.jsx
Normal file
159
client/src/components/attachments/Attachments/ItemContent.jsx
Normal 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);
|
|
@ -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);
|
||||
}
|
||||
}
|
8
client/src/components/attachments/Attachments/index.js
Normal file
8
client/src/components/attachments/Attachments/index.js
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue