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,157 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, Header, Icon, TextArea } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
import { Input } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useClosableModal, useForm, useNestedRef } from '../../../hooks';
import { isModifierKeyPressed } from '../../../utils/event-helpers';
import { ProjectTypes } from '../../../constants/Enums';
import { ProjectTypeIcons } from '../../../constants/Icons';
import SelectTypeStep from './SelectTypeStep';
import styles from './AddProjectModal.module.scss';
const AddProjectModal = React.memo(() => {
const defaultType = useSelector(
(state) => selectors.selectCurrentModal(state).params.defaultType,
);
const { data: defaultData, isSubmitting } = useSelector(selectors.selectProjectCreateForm);
const dispatch = useDispatch();
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(() => ({
name: '',
description: '',
type: ProjectTypes.PRIVATE,
...defaultData,
...(defaultType && {
type: defaultType,
}),
}));
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const submit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
description: data.description.trim() || null,
};
if (!cleanData.name) {
nameFieldRef.current.select();
return;
}
dispatch(entryActions.createProject(cleanData));
}, [dispatch, data, nameFieldRef]);
const handleClose = useCallback(() => {
dispatch(entryActions.closeModal());
}, [dispatch]);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
const handleDescriptionKeyDown = useCallback(
(event) => {
if (isModifierKeyPressed(event) && event.key === 'Enter') {
submit();
}
},
[submit],
);
const handleTypeSelect = useCallback(
(type) => {
setData((prevData) => ({
...prevData,
type,
}));
},
[setData],
);
const [ClosableModal, , activateClosable, deactivateClosable] = useClosableModal();
const handleSelectTypeClose = useCallback(() => {
deactivateClosable();
nameFieldRef.current.focus();
}, [deactivateClosable, nameFieldRef]);
useEffect(() => {
nameFieldRef.current.focus();
}, [nameFieldRef]);
const SelectTypePopup = usePopup(SelectTypeStep, {
onOpen: activateClosable,
onClose: handleSelectTypeClose,
});
return (
<ClosableModal basic closeIcon size="tiny" onClose={handleClose}>
<ClosableModal.Content>
<Header inverted size="huge">
{t('common.createProject', {
context: 'title',
})}
</Header>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
inverted
ref={handleNameFieldRef}
name="name"
value={data.name}
maxLength={128}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.description')}</div>
<TextArea
as={TextareaAutosize}
name="description"
value={data.description}
maxLength={1024}
minRows={2}
spellCheck={false}
className={styles.field}
onKeyDown={handleDescriptionKeyDown}
onChange={handleFieldChange}
/>
<Button
inverted
color="green"
icon="checkmark"
content={t('action.createProject')}
loading={isSubmitting}
disabled={isSubmitting}
/>
<SelectTypePopup value={data.type} onSelect={handleTypeSelect}>
<Button type="button" className={styles.selectTypeButton}>
<Icon name={ProjectTypeIcons[data.type]} className={styles.selectTypeButtonIcon} />
{t(`common.${data.type}`)}
</Button>
</SelectTypePopup>
</Form>
</ClosableModal.Content>
</ClosableModal>
);
});
export default AddProjectModal;

View File

@@ -0,0 +1,38 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
margin-bottom: 14px;
}
.selectTypeButton {
background: transparent;
box-shadow: none;
color: #6b808c;
float: right;
font-weight: normal;
margin-left: auto;
margin-right: 0;
padding: 6px 11px;
text-align: left;
text-decoration: underline;
transition: none;
white-space: nowrap;
&:hover {
background: rgba(246, 225, 189, 0.08);
color: #fff;
}
}
.selectTypeButtonIcon {
text-decoration: none;
}
.text {
padding-bottom: 6px;
}
}

View File

@@ -0,0 +1,70 @@
/*!
* 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 { useTranslation } from 'react-i18next';
import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import { ProjectTypes } from '../../../constants/Enums';
import { ProjectTypeIcons } from '../../../constants/Icons';
import styles from './SelectTypeStep.module.scss';
const DESCRIPTION_BY_TYPE = {
[ProjectTypes.PRIVATE]: 'common.forPersonalProjects',
[ProjectTypes.SHARED]: 'common.forTeamBasedProjects',
};
const SelectTypeStep = React.memo(({ value, onSelect, onClose }) => {
const [t] = useTranslation();
const handleSelectClick = useCallback(
(_, { value: nextValue }) => {
if (nextValue !== value) {
onSelect(nextValue);
}
onClose();
},
[value, onSelect, onClose],
);
return (
<>
<Popup.Header>
{t('common.selectType', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{[ProjectTypes.PRIVATE, ProjectTypes.SHARED].map((type) => (
<Menu.Item
key={type}
value={type}
active={type === value}
className={styles.menuItem}
onClick={handleSelectClick}
>
<Icon name={ProjectTypeIcons[type]} className={styles.menuItemIcon} />
<div className={styles.menuItemTitle}>{t(`common.${type}`)}</div>
<p className={styles.menuItemDescription}>{t(DESCRIPTION_BY_TYPE[type])}</p>
</Menu.Item>
))}
</Menu>
</Popup.Content>
</>
);
});
SelectTypeStep.propTypes = {
value: PropTypes.string.isRequired,
onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default SelectTypeStep;

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) {
.menu {
margin: 0 auto 8px;
width: 100%;
}
.menuItem:last-child {
margin-bottom: 0;
}
.menuItemDescription {
opacity: 0.5;
}
.menuItemIcon {
float: left;
margin: 0 0.35714286em 0 0;
}
.menuItemTitle {
margin-bottom: 8px;
}
}

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 AddProjectModal from './AddProjectModal';
export default AddProjectModal;

View File

@@ -0,0 +1,45 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import { useSelector } from 'react-redux';
import selectors from '../../../selectors';
import ModalTypes from '../../../constants/ModalTypes';
import ProjectSettingsModal from '../ProjectSettingsModal';
import Boards from '../../boards/Boards';
import BoardSettingsModal from '../../boards/BoardSettingsModal';
import styles from './Project.module.scss';
const Project = React.memo(() => {
const modal = useSelector(selectors.selectCurrentModal);
let modalNode = null;
if (modal) {
switch (modal.type) {
case ModalTypes.PROJECT_SETTINGS:
modalNode = <ProjectSettingsModal />;
break;
case ModalTypes.BOARD_SETTINGS:
modalNode = <BoardSettingsModal />;
break;
default:
}
}
return (
<>
<div className={styles.wrapper}>
<Boards />
</div>
{modalNode}
</>
);
});
export default Project;

View File

@@ -0,0 +1,14 @@
/*!
* 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: rgba(0, 0, 0, 0.16);
display: flex;
flex-direction: column;
height: 100%;
padding: 10px 20px 0;
}
}

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 Project from './Project';
export default Project;

View File

@@ -0,0 +1,53 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import selectors from '../../../selectors';
import { ProjectBackgroundTypes } from '../../../constants/Enums';
import styles from './ProjectBackground.module.scss';
import globalStyles from '../../../styles.module.scss';
const ProjectBackground = React.memo(() => {
const selectBackgroundImageById = useMemo(() => selectors.makeSelectBackgroundImageById(), []);
const { backgroundImageId, backgroundType, backgroundGradient } = useSelector(
selectors.selectCurrentProject,
);
const backgroundImageUrl = useSelector((state) => {
if (!backgroundType || backgroundType !== ProjectBackgroundTypes.IMAGE) {
return null;
}
const backgroundImage = selectBackgroundImageById(state, backgroundImageId);
if (!backgroundImage) {
return null;
}
return backgroundImage.url;
});
return (
<div
className={classNames(
styles.wrapper,
backgroundType === ProjectBackgroundTypes.GRADIENT &&
globalStyles[`background${upperFirst(camelCase(backgroundGradient))}`],
)}
style={{
background: backgroundImageUrl && `url("${backgroundImageUrl}") center / cover`,
}}
/>
);
});
export default ProjectBackground;

View File

@@ -0,0 +1,13 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
height: 100%;
position: fixed;
width: 100%;
z-index: -1;
}
}

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 ProjectBackground from './ProjectBackground';
export default ProjectBackground;

View File

@@ -0,0 +1,188 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Button, Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import Paths from '../../../constants/Paths';
import { ProjectBackgroundTypes } from '../../../constants/Enums';
import UserAvatar from '../../users/UserAvatar';
import styles from './ProjectCard.module.scss';
import globalStyles from '../../../styles.module.scss';
const Sizes = {
SMALL: 'small',
LARGE: 'large',
};
const ProjectCard = React.memo(
({ id, size, isActive, withDescription, withTypeIndicator, withFavoriteButton, className }) => {
const selectProjectById = useMemo(() => selectors.makeSelectProjectById(), []);
const selectFirstBoardIdByProjectId = useMemo(
() => selectors.makeSelectFirstBoardIdByProjectId(),
[],
);
const selectNotificationsTotalByProjectId = useMemo(
() => selectors.makeSelectNotificationsTotalByProjectId(),
[],
);
const selectProjectManagerById = useMemo(() => selectors.makeSelectProjectManagerById(), []);
const selectBackgroundImageById = useMemo(() => selectors.makeSelectBackgroundImageById(), []);
const project = useSelector((state) => selectProjectById(state, id));
const firstBoardId = useSelector((state) => selectFirstBoardIdByProjectId(state, id));
const notificationsTotal = useSelector((state) =>
selectNotificationsTotalByProjectId(state, id),
);
const ownerProjectManager = useSelector(
(state) =>
project.ownerProjectManagerId &&
selectProjectManagerById(state, project.ownerProjectManagerId),
);
const backgroundImageUrl = useSelector((state) => {
if (!project.backgroundType || project.backgroundType !== ProjectBackgroundTypes.IMAGE) {
return null;
}
const backgroundImage = selectBackgroundImageById(state, project.backgroundImageId);
if (!backgroundImage) {
return null;
}
return backgroundImage.thumbnailUrls.outside360;
});
const dispatch = useDispatch();
const handleToggleFavoriteClick = useCallback(() => {
dispatch(
entryActions.updateProject(project.id, {
isFavorite: !project.isFavorite,
}),
);
}, [project, dispatch]);
const withSidebar = withTypeIndicator || (withFavoriteButton && !project.isHidden);
return (
<div
className={classNames(
className,
styles.wrapper,
styles[`wrapper${upperFirst(size)}`],
project.isHidden && styles.wrapperHidden,
)}
>
<Link
to={
firstBoardId
? Paths.BOARDS.replace(':id', firstBoardId)
: Paths.PROJECTS.replace(':id', id)
}
className={styles.content}
>
<div
className={classNames(
styles.cover,
project.backgroundType === ProjectBackgroundTypes.GRADIENT &&
globalStyles[`background${upperFirst(camelCase(project.backgroundGradient))}`],
)}
style={{
background: backgroundImageUrl && `url("${backgroundImageUrl}") center / cover`,
}}
/>
{notificationsTotal > 0 && (
<span className={styles.notifications}>{notificationsTotal}</span>
)}
<div
className={classNames(styles.information, withSidebar && styles.informationWithSidebar)}
>
<div
className={classNames(
styles.title,
isActive !== undefined && styles.titleActivatable,
isActive && styles.titleActive,
)}
>
{project.name}
</div>
{withDescription && project.description && (
<div className={styles.description}>{project.description}</div>
)}
</div>
{withTypeIndicator && (
<div
className={classNames(
styles.typeIndicator,
ownerProjectManager && styles.typeIndicatorWithUser,
)}
>
{ownerProjectManager ? (
<UserAvatar id={ownerProjectManager.userId} size="small" />
) : (
<Icon
fitted
name="group"
className={classNames(styles.icon, styles.typeIndicatorIcon)}
/>
)}
</div>
)}
</Link>
{withFavoriteButton && !project.isHidden && (
<Button
className={classNames(
styles.favoriteButton,
!project.isFavorite && styles.favoriteButtonAppearable,
)}
onClick={handleToggleFavoriteClick}
>
<Icon
fitted
name={project.isFavorite ? 'star' : 'star outline'}
className={classNames(styles.icon, styles.favoriteButtonIcon)}
/>
</Button>
)}
</div>
);
},
);
ProjectCard.propTypes = {
id: PropTypes.string.isRequired,
size: PropTypes.oneOf(Object.values(Sizes)),
isActive: PropTypes.bool,
withDescription: PropTypes.bool,
withTypeIndicator: PropTypes.bool,
withFavoriteButton: PropTypes.bool,
className: PropTypes.string.isRequired,
};
ProjectCard.defaultProps = {
size: Sizes.LARGE,
isActive: undefined,
withDescription: false,
withTypeIndicator: false,
withFavoriteButton: false,
};
export default ProjectCard;

View File

@@ -0,0 +1,196 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.content {
color: #fff;
}
.cover {
background: #555;
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: filter 0.2s ease;
&::after {
background: #000;
bottom: 0;
content: "";
left: 0;
opacity: 0.3;
position: absolute;
right: 0;
top: 0;
}
}
.description {
border-left: 1px solid;
display: -webkit-box;
font-weight: lighter;
hyphens: auto;
left: 0;
line-height: 1.2;
opacity: 0;
overflow: hidden;
position: absolute;
top: 0;
transition: opacity 0.3s, transform 0.3s;
transform: translate(-40px, 0);
white-space: pre-line;
word-break: break-word;
-webkit-box-orient: vertical;
}
.favoriteButton {
background: none;
box-shadow: none;
height: 36px;
line-height: 1;
margin: 0;
padding: 0;
position: absolute;
right: 4px;
top: 4px;
width: 36px;
&:hover {
background: rgba(0, 0, 0, 0.12);
}
}
.favoriteButtonAppearable {
opacity: 0;
}
.icon {
color: rgba(255, 255, 255, 0.8);
font-size: 18px;
}
.information {
height: 100%;
position: relative;
}
.notifications {
background: #eb5a46;
border: none;
border-radius: 3px;
outline: none;
padding: 0px 6px;
position: absolute;
transition: background 0.3s ease;
}
.title {
bottom: 0;
display: -webkit-box;
hyphens: auto;
left: 0;
line-height: 1.1;
overflow: hidden;
position: absolute;
word-break: break-word;
-webkit-box-orient: vertical;
}
.titleActivatable {
color: rgba(255, 255, 255, 0.72);
}
.titleActive {
color: #fff;
font-weight: bold;
}
.typeIndicator {
bottom: 12px;
margin-top: auto;
position: absolute;
right: 12px;
}
.typeIndicatorIcon {
opacity: 0.8;
}
.typeIndicatorWithUser {
bottom: 12px;
right: 8px;
}
.wrapper {
border-radius: 4px;
overflow: hidden;
position: relative;
&:hover {
.cover {
filter: brightness(0.8);
}
.description {
opacity: 1;
transform: translate(0, 0);
}
.favoriteButtonAppearable {
opacity: 1;
}
.titleActivatable {
color: #fff;
}
}
}
.wrapperHidden {
opacity: 0.5;
}
/* Sizes */
.wrapperSmall {
.notifications {
font-size: 10px;
line-height: 18px;
}
.title {
line-clamp: 2;
-webkit-line-clamp: 2;
margin: 0 12px 10px 12px;
}
}
.wrapperLarge {
.description {
line-clamp: 3;
-webkit-line-clamp: 3;
margin: 24px 20px 0 20px;
padding-left: 10px;
}
.informationWithSidebar {
margin-right: 20px;
}
.notifications {
font-size: 12px;
line-height: 20px;
}
.title {
font-size: 22px;
line-clamp: 2;
-webkit-line-clamp: 2;
margin: 0 20px 14px 20px;
}
}
}

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 ProjectCard from './ProjectCard';
export default ProjectCard;

View File

@@ -0,0 +1,84 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useDropzone } from 'react-dropzone';
import styles from './AddImageZone.module.scss';
const AddImageZone = React.memo(({ children, onCreate }) => {
const [t] = useTranslation();
const handleDropAccepted = useCallback(
(files) => {
onCreate(files[0]);
},
[onCreate],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'image/*': [],
},
multiple: false,
noClick: true,
noKeyboard: true,
onDropAccepted: handleDropAccepted,
});
useEffect(() => {
const handlePaste = (event) => {
if (!event.clipboardData) {
return;
}
const file = event.clipboardData.files[0];
if (file) {
if (!file.type.startsWith('image/')) {
return;
}
onCreate(file);
return;
}
const item = event.clipboardData.items[0];
if (!item || !item.type.startsWith('image/')) {
return;
}
if (item.kind === 'file') {
onCreate(item.getAsFile());
}
};
window.addEventListener('paste', handlePaste);
return () => {
window.removeEventListener('paste', handlePaste);
};
}, [onCreate]);
return (
/* eslint-disable-next-line react/jsx-props-no-spreading */
<div {...getRootProps()}>
{isDragActive && <div className={styles.dropzone}>{t('common.dropFileToUpload')}</div>}
{children}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<input {...getInputProps()} />
</div>
);
});
AddImageZone.propTypes = {
children: PropTypes.node.isRequired,
onCreate: PropTypes.func.isRequired,
};
export default AddImageZone;

View File

@@ -0,0 +1,20 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.dropzone {
background: white;
font-size: 20px;
font-weight: bold;
height: 100%;
line-height: 30px;
opacity: 0.7;
padding-top: 200px;
position: absolute;
text-align: center;
width: 100%;
z-index: 2001;
}
}

View File

@@ -0,0 +1,75 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Tab } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { ProjectBackgroundTypes } from '../../../../constants/Enums';
import Gradients from './Gradients';
import Images from './Images';
import AddImageZone from './AddImageZone';
import styles from './BackgroundPane.module.scss';
const TITLE_BY_TYPE = {
[ProjectBackgroundTypes.GRADIENT]: 'common.gradients',
[ProjectBackgroundTypes.IMAGE]: 'common.uploadedImages',
};
const BackgroundPane = React.memo(() => {
const { backgroundType: currentType } = useSelector(selectors.selectCurrentProject);
const dispatch = useDispatch();
const [t] = useTranslation();
const [activeType, setActiveType] = useState(
() => currentType || ProjectBackgroundTypes.GRADIENT,
);
const handleImageCreate = useCallback(
(file) => {
dispatch(
entryActions.createBackgroundImageInCurrentProject({
file,
}),
);
setActiveType(ProjectBackgroundTypes.IMAGE);
},
[dispatch],
);
const handleActiveTypeChange = useCallback((_, { value }) => {
setActiveType(value);
}, []);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<AddImageZone onCreate={handleImageCreate}>
<Button.Group fluid basic className={styles.activeTypeButtonGroup}>
{[ProjectBackgroundTypes.GRADIENT, ProjectBackgroundTypes.IMAGE].map((type) => (
<Button
key={type}
type="button"
value={type}
active={type === activeType}
onClick={handleActiveTypeChange}
>
{t(TITLE_BY_TYPE[type])}
</Button>
))}
</Button.Group>
{activeType === ProjectBackgroundTypes.GRADIENT && <Gradients />}
{activeType === ProjectBackgroundTypes.IMAGE && <Images />}
</AddImageZone>
</Tab.Pane>
);
});
export default BackgroundPane;

View File

@@ -0,0 +1,15 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.activeTypeButtonGroup {
margin-bottom: 12px;
}
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import BACKGROUND_GRADIENTS from '../../../../../constants/BackgroundGradients';
import Item from './Item';
import styles from './Gradients.module.scss';
const Gradients = React.memo(() => (
<div className={styles.wrapper}>
{BACKGROUND_GRADIENTS.map((backgroundGradient) => (
<Item key={backgroundGradient} name={backgroundGradient} />
))}
</div>
));
export default Gradients;

View File

@@ -0,0 +1,39 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
max-height: 60vh;
overflow-y: auto;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&:after {
clear: both;
content: "";
display: table;
}
&::-webkit-scrollbar {
width: 9px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border-left: 0.25em transparent solid;
border-radius: 3px;
}
}
}

View File

@@ -0,0 +1,74 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { Button, Label } from 'semantic-ui-react';
import selectors from '../../../../../selectors';
import entryActions from '../../../../../entry-actions';
import { ProjectBackgroundTypes } from '../../../../../constants/Enums';
import styles from './Item.module.scss';
import globalStyles from '../../../../../styles.module.scss';
const Item = React.memo(({ name }) => {
const isActive = useSelector((state) => {
const { backgroundType, backgroundGradient } = selectors.selectCurrentProject(state);
return backgroundType === ProjectBackgroundTypes.GRADIENT && name === backgroundGradient;
});
const dispatch = useDispatch();
const handleClick = useCallback(() => {
dispatch(
entryActions.updateCurrentProject(
isActive
? {
backgroundType: null,
backgroundGradient: null,
}
: {
backgroundType: ProjectBackgroundTypes.GRADIENT,
backgroundGradient: name,
},
),
);
}, [name, isActive, dispatch]);
return (
<Button
type="button"
className={classNames(
styles.wrapper,
globalStyles[`background${upperFirst(camelCase(name))}`],
)}
onClick={handleClick}
>
{isActive && (
<Label
corner="left"
size="mini"
icon={{
name: 'checkmark',
color: 'grey',
inverted: true,
}}
className={styles.label}
/>
)}
</Button>
);
});
Item.propTypes = {
name: PropTypes.string.isRequired,
};
export default Item;

View File

@@ -0,0 +1,25 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.label {
border-color: rgba(29, 46, 63, 0.8);
i {
cursor: inherit;
}
}
.wrapper {
height: 74px;
margin: 0;
padding: 0;
position: relative;
&:hover {
opacity: 0.9;
}
}
}

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 Gradients from './Gradients';
export default Gradients;

View File

@@ -0,0 +1,76 @@
/*!
* 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 { Button, Icon, Label } from 'semantic-ui-react';
import { usePopupInClosableContext } from '../../../../hooks';
import ConfirmationStep from '../../../common/ConfirmationStep';
import styles from './Image.module.scss';
const Image = React.memo(({ url, isActive, onSelect, onDeselect, onDelete }) => {
const handleClick = useCallback(() => {
if (isActive) {
onDeselect();
} else {
onSelect();
}
}, [isActive, onSelect, onDeselect]);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<div
className={styles.wrapper}
style={{
background: `url("${url}") center / cover`,
}}
onClick={handleClick}
>
{isActive && (
<Label
corner="left"
size="mini"
icon={{
name: 'checkmark',
color: 'grey',
inverted: true,
}}
className={styles.label}
/>
)}
{onDelete && (
<ConfirmationPopup
title="common.deleteBackgroundImage"
content="common.areYouSureYouWantToDeleteThisBackgroundImage"
buttonContent="action.deleteBackgroundImage"
onConfirm={onDelete}
>
<Button className={styles.deleteButton}>
<Icon fitted name="trash alternate" size="small" />
</Button>
</ConfirmationPopup>
)}
</div>
);
});
Image.propTypes = {
url: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onDelete: PropTypes.func,
};
Image.defaultProps = {
onDelete: undefined,
};
export default Image;

View File

@@ -0,0 +1,43 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.deleteButton {
background: rgba(255, 255, 255, 0.9);
box-shadow: none;
line-height: 1;
margin: 0;
min-height: 24px;
opacity: 0;
padding: 0;
position: absolute;
right: 4px;
top: 4px;
width: 24px;
}
.label {
border-color: rgba(29, 46, 63, 0.8);
i {
cursor: inherit;
}
}
.wrapper {
border-radius: 3px;
cursor: pointer;
height: 74px;
position: relative;
&:hover {
opacity: 0.9;
.deleteButton {
opacity: 1;
}
}
}
}

View File

@@ -0,0 +1,65 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { FilePicker } from '../../../../../lib/custom-ui';
import selectors from '../../../../../selectors';
import entryActions from '../../../../../entry-actions';
import Item from './Item';
import styles from './Images.module.scss';
const Images = React.memo(() => {
const backgroundImageIds = useSelector(selectors.selectBackgroundImageIdsForCurrentProject);
const dispatch = useDispatch();
const [t] = useTranslation();
const fieldRef = useRef(null);
const handleFileSelect = useCallback(
(file) => {
dispatch(
entryActions.createBackgroundImageInCurrentProject({
file,
}),
);
},
[dispatch],
);
useEffect(() => {
fieldRef.current.focus();
}, []);
return (
<>
<div className={styles.images}>
{backgroundImageIds.map((backgroundImageId) => (
<Item key={backgroundImageId} id={backgroundImageId} />
))}
</div>
<div className={styles.actions}>
<div className={styles.action}>
<FilePicker accept="image/*" onSelect={handleFileSelect}>
<Button
ref={fieldRef}
content={t('action.uploadNewImage', {
context: 'title',
})}
className={styles.actionButton}
/>
</FilePicker>
</div>
</div>
</>
);
});
export default Images;

View File

@@ -0,0 +1,64 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
&:hover {
background: #e9e9e9;
}
}
.actions {
margin-top: 20px;
}
.actionButton {
background: transparent;
color: #6b808c;
font-weight: normal;
height: 36px;
line-height: 24px;
padding: 6px 12px;
text-align: left;
text-decoration: underline;
width: 100%;
}
.images {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
max-height: 60vh;
overflow-y: auto;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&::-webkit-scrollbar {
width: 9px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border-left: 0.25em transparent solid;
border-radius: 3px;
}
}
}

View File

@@ -0,0 +1,75 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { Loader } from 'semantic-ui-react';
import selectors from '../../../../../selectors';
import entryActions from '../../../../../entry-actions';
import { ProjectBackgroundTypes } from '../../../../../constants/Enums';
import Image from '../Image';
import styles from './Item.module.scss';
const Item = React.memo(({ id }) => {
const selectBackgroundImageById = useMemo(() => selectors.makeSelectBackgroundImageById(), []);
const backgroundImage = useSelector((state) => selectBackgroundImageById(state, id));
const isActive = useSelector((state) => {
const { backgroundType, backgroundImageId } = selectors.selectCurrentProject(state);
return backgroundType === ProjectBackgroundTypes.IMAGE && id === backgroundImageId;
});
const dispatch = useDispatch();
const handleSelect = useCallback(() => {
dispatch(
entryActions.updateCurrentProject({
backgroundType: ProjectBackgroundTypes.IMAGE,
backgroundImageId: id,
}),
);
}, [id, dispatch]);
const handleDeselect = useCallback(() => {
dispatch(
entryActions.updateCurrentProject({
backgroundType: null,
backgroundImageId: null,
}),
);
}, [dispatch]);
const handleDelete = useCallback(() => {
dispatch(entryActions.deleteBackgroundImage(id));
}, [id, dispatch]);
if (!backgroundImage.isPersisted) {
return (
<div className={styles.wrapperSubmitting}>
<Loader inverted />
</div>
);
}
return (
<Image
url={backgroundImage.thumbnailUrls.outside360}
isActive={isActive}
onSelect={handleSelect}
onDeselect={handleDeselect}
onDelete={handleDelete}
/>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
};
export default Item;

View File

@@ -0,0 +1,13 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapperSubmitting {
background: rgba(9, 30, 66, 0.04);
border-radius: 3px;
height: 74px;
position: relative;
}
}

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 Images from './Images';
export default Images;

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 BackgroundPane from './BackgroundPane';
export default BackgroundPane;

View File

@@ -0,0 +1,50 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Icon, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { usePopupInClosableContext } from '../../../hooks';
import BaseCustomFieldGroupChip from '../../base-custom-field-groups/BaseCustomFieldGroupChip';
import BaseCustomFieldGroupStep from '../../base-custom-field-groups/BaseCustomFieldGroupStep';
import AddBaseCustomFieldGroupStep from '../../base-custom-field-groups/AddBaseCustomFieldGroupStep';
import styles from './BaseCustomFieldGroupsPane.module.scss';
const BaseCustomFieldGroupsPane = React.memo(() => {
const baseCustomFieldGroupIds = useSelector(
selectors.selectBaseCustomFieldGroupIdsForCurrentProject,
);
const BaseCustomFieldGroupPopup = usePopupInClosableContext(BaseCustomFieldGroupStep);
const AddBaseCustomFieldGroupPopup = usePopupInClosableContext(AddBaseCustomFieldGroupStep);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<div className={styles.attachments}>
{baseCustomFieldGroupIds.map((baseCustomFieldGroupId) => (
<span key={baseCustomFieldGroupId} className={styles.attachment}>
<BaseCustomFieldGroupPopup id={baseCustomFieldGroupId}>
<BaseCustomFieldGroupChip id={baseCustomFieldGroupId} />
</BaseCustomFieldGroupPopup>
</span>
))}
<AddBaseCustomFieldGroupPopup>
<button
type="button"
className={classNames(styles.attachment, styles.addAttachmentButton)}
>
<Icon name="plus" size="small" className={styles.addAttachmentButtonIcon} />
</button>
</AddBaseCustomFieldGroupPopup>
</div>
</Tab.Pane>
);
});
export default BaseCustomFieldGroupsPane;

View File

@@ -0,0 +1,47 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.addAttachmentButton {
background: rgba(9, 30, 66, 0.04);
border: none;
border-radius: 3px;
color: #6b808c;
cursor: pointer;
line-height: 20px;
padding: 6px 14px;
transition: background 0.3s ease;
vertical-align: top;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #17394d;
}
}
.addAttachmentButtonIcon {
margin: 0 -4.3px;
}
.attachment {
display: inline-block;
margin: 0 4px 4px 0;
max-width: 100%;
}
.attachments {
display: inline-block;
line-height: 1;
margin: 0 8px 8px 0;
max-width: 100%;
vertical-align: top;
}
.wrapper {
border: none;
box-shadow: none;
display: flex;
}
}

View File

@@ -0,0 +1,102 @@
/*!
* 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, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, Input, TextArea } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useForm, useNestedRef } from '../../../../hooks';
import { isModifierKeyPressed } from '../../../../utils/event-helpers';
import styles from './EditInformation.module.scss';
const EditInformation = React.memo(() => {
const project = useSelector(selectors.selectCurrentProject);
const dispatch = useDispatch();
const [t] = useTranslation();
const defaultData = useMemo(
() => ({
name: project.name,
description: project.description,
}),
[project.name, project.description],
);
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
description: defaultData.description || '',
}));
const cleanData = useMemo(
() => ({
...data,
name: data.name.trim(),
description: data.description.trim() || null,
}),
[data],
);
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const submit = useCallback(() => {
if (!cleanData.name) {
nameFieldRef.current.select();
return;
}
dispatch(entryActions.updateCurrentProject(cleanData));
}, [dispatch, cleanData, nameFieldRef]);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
const handleDescriptionKeyDown = useCallback(
(event) => {
if (isModifierKeyPressed(event) && event.key === 'Enter') {
submit();
}
},
[submit],
);
return (
<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}
/>
<div className={styles.text}>{t('common.description')}</div>
<TextArea
as={TextareaAutosize}
name="description"
value={data.description}
maxLength={1024}
minRows={2}
spellCheck={false}
className={styles.field}
onKeyDown={handleDescriptionKeyDown}
onChange={handleFieldChange}
/>
<Button positive disabled={dequal(cleanData, defaultData)} content={t('action.save')} />
</Form>
);
});
export default EditInformation;

View File

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

View File

@@ -0,0 +1,101 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Header, Radio, Tab } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { usePopupInClosableContext } from '../../../../hooks';
import EditInformation from './EditInformation';
import ConfirmationStep from '../../../common/ConfirmationStep';
import styles from './GeneralPane.module.scss';
const GeneralPane = React.memo(() => {
const project = useSelector(selectors.selectCurrentProject);
const hasBoards = useSelector(
(state) => selectors.selectBoardIdsForCurrentProject(state).length > 0,
);
const canEdit = useSelector(selectors.selectIsCurrentUserManagerForCurrentProject);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleToggleChange = useCallback(
(_, { name: fieldName, checked }) => {
dispatch(
entryActions.updateCurrentProject({
[fieldName]: checked,
}),
);
},
[dispatch],
);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteCurrentProject());
}, [dispatch]);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
{canEdit && (
<>
<EditInformation />
<Divider horizontal section>
<Header as="h4">
{t('common.display', {
context: 'title',
})}
</Header>
</Divider>
</>
)}
<Radio
toggle
name="isHidden"
checked={project.isHidden}
label={t('common.hideFromProjectListAndFavorites')}
className={styles.radio}
onChange={handleToggleChange}
/>
{canEdit && (
<>
<Divider horizontal section>
<Header as="h4">
{t('common.dangerZone', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<ConfirmationPopup
title="common.deleteProject"
content="common.areYouSureYouWantToDeleteThisProject"
buttonContent="action.deleteProject"
onConfirm={handleDeleteConfirm}
>
<Button disabled={hasBoards} className={styles.actionButton}>
{hasBoards
? t('common.deleteAllBoardsToBeAbleToDeleteThisProject')
: t('action.deleteProject', {
context: 'title',
})}
</Button>
</ConfirmationPopup>
</div>
</>
)}
</Tab.Pane>
);
});
export default GeneralPane;

View File

@@ -0,0 +1,47 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
&:has(:enabled):hover {
background: #e9e9e9;
}
}
.actionButton {
background: transparent;
color: #6b808c;
font-weight: normal;
height: 36px;
line-height: 24px;
padding: 6px 12px;
text-align: left;
text-decoration: underline;
width: 100%;
}
.radio {
margin-bottom: 16px;
width: 100%;
&:last-child {
margin-bottom: 0;
}
}
.wrapper {
border: none;
box-shadow: none;
}
}

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 GeneralPane from './GeneralPane';
export default GeneralPane;

View File

@@ -0,0 +1,71 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Header, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { usePopupInClosableContext } from '../../../hooks';
import ConfirmationStep from '../../common/ConfirmationStep';
import ProjectManagers from '../../project-managers/ProjectManagers';
import styles from './ManagersPane.module.scss';
const ManagersPane = React.memo(() => {
// TODO: rename?
const isShared = useSelector(
(state) => !selectors.selectCurrentProject(state).ownerProjectManagerId,
);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleMakeSharedConfirm = useCallback(() => {
dispatch(
entryActions.updateCurrentProject({
ownerProjectManagerId: null,
}),
);
}, [dispatch]);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<ProjectManagers />
{!isShared && (
<>
<Divider horizontal section>
<Header as="h4">
{t('common.dangerZone', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<ConfirmationPopup
title="common.makeProjectShared"
content="common.areYouSureYouWantToMakeThisProjectShared"
buttonType="positive"
buttonContent="action.makeProjectShared"
onConfirm={handleMakeSharedConfirm}
>
<Button className={styles.actionButton}>
{t('action.makeProjectShared', {
context: 'title',
})}
</Button>
</ConfirmationPopup>
</div>
</>
)}
</Tab.Pane>
);
});
export default ManagersPane;

View File

@@ -0,0 +1,38 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
&:hover {
background: #e9e9e9;
}
}
.actionButton {
background: transparent;
color: #6b808c;
font-weight: normal;
height: 36px;
line-height: 24px;
padding: 6px 12px;
text-align: left;
text-decoration: underline;
width: 100%;
}
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -0,0 +1,94 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import GeneralPane from './GeneralPane';
import ManagersPane from './ManagersPane';
import BackgroundPane from './BackgroundPane';
import BaseCustomFieldGroupsPane from './BaseCustomFieldGroupsPane';
import styles from './ProjectSettingsModal.module.scss';
const ProjectSettingsModal = React.memo(() => {
const withManagablePanes = useSelector(selectors.selectIsCurrentUserManagerForCurrentProject);
const dispatch = useDispatch();
const [t] = useTranslation();
const [activeTabIndex, setActiveTabIndex] = useState(0);
const handleClose = useCallback(() => {
dispatch(entryActions.closeModal());
}, [dispatch]);
const handleTabChange = useCallback((_, { activeIndex }) => {
setActiveTabIndex(activeIndex);
}, []);
const [ClosableModal] = useClosableModal();
const panes = [
{
menuItem: t('common.general', {
context: 'title',
}),
render: () => <GeneralPane />,
},
{
menuItem: t('common.managers', {
context: 'title',
}),
render: () => <ManagersPane />,
},
];
if (withManagablePanes) {
panes.push(
{
menuItem: t('common.background', {
context: 'title',
}),
render: () => <BackgroundPane />,
},
{
menuItem: t('common.baseCustomFields', {
context: 'title',
}),
render: () => <BaseCustomFieldGroupsPane />,
},
);
}
const isBackgroundPaneActive = withManagablePanes && activeTabIndex === 2;
return (
<ClosableModal
closeIcon
size="small"
centered={false}
dimmer={isBackgroundPaneActive && { className: styles.dimmerTransparent }}
onClose={handleClose}
>
<ClosableModal.Content>
<Tab
menu={{
secondary: true,
pointing: true,
}}
panes={panes}
onTabChange={handleTabChange}
/>
</ClosableModal.Content>
</ClosableModal>
);
});
export default ProjectSettingsModal;

View File

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

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 ProjectSettingsModal from './ProjectSettingsModal';
export default ProjectSettingsModal;