1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-27 17:19:43 +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,66 @@
/*!
* 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 classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Modal, Tab } from 'semantic-ui-react';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import UsersPane from './UsersPane';
import styles from './AdministrationModal.module.scss';
const AdministrationModal = React.memo(() => {
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.users', {
context: 'title',
}),
render: () => <UsersPane />,
},
];
const isUsersPaneActive = activeTabIndex === 0;
return (
<ClosableModal
closeIcon
size={isUsersPaneActive ? 'large' : 'small'}
centered={false}
className={classNames(isUsersPaneActive && styles.wrapperUsers)}
onClose={handleClose}
>
<Modal.Content>
<Tab
menu={{
secondary: true,
pointing: true,
}}
panes={panes}
onTabChange={handleTabChange}
/>
</Modal.Content>
</ClosableModal>
);
});
export default AdministrationModal;

View file

@ -0,0 +1,18 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapperUsers {
margin: 2rem auto;
@media (width < 926px) {
margin: 1rem auto;
}
@media (768px <= width < 1160px) {
width: 88%;
}
}
}

View file

@ -0,0 +1,250 @@
/*!
* 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 { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useSteps } from '../../../../hooks';
import SelectRoleStep from './SelectRoleStep';
import ConfirmationStep from '../../ConfirmationStep';
import EditUserInformationStep from '../../../users/EditUserInformationStep';
import EditUserUsernameStep from '../../../users/EditUserUsernameStep';
import EditUserEmailStep from '../../../users/EditUserEmailStep';
import EditUserPasswordStep from '../../../users/EditUserPasswordStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
EDIT_INFORMATION: 'EDIT_INFORMATION',
EDIT_USERNAME: 'EDIT_USERNAME',
EDIT_EMAIL: 'EDIT_EMAIL',
EDIT_PASSWORD: 'EDIT_PASSWORD',
EDIT_ROLE: 'EDIT_ROLE',
ACTIVATE: 'ACTIVATE',
DEACTIVATE: 'DEACTIVATE',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(({ userId, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
const user = useSelector((state) => selectUserById(state, userId));
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleRoleSelect = useCallback(
(role) => {
dispatch(
entryActions.updateUser(userId, {
role,
}),
);
},
[userId, dispatch],
);
const handleActivateConfirm = useCallback(() => {
dispatch(
entryActions.updateUser(userId, {
isDeactivated: false,
}),
);
onClose();
}, [userId, onClose, dispatch]);
const handleDeactivateConfirm = useCallback(() => {
dispatch(
entryActions.updateUser(userId, {
isDeactivated: true,
}),
);
onClose();
}, [userId, onClose, dispatch]);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteUser(userId));
}, [userId, dispatch]);
const handleEditInformationClick = useCallback(() => {
openStep(StepTypes.EDIT_INFORMATION);
}, [openStep]);
const handleEditUsernameClick = useCallback(() => {
openStep(StepTypes.EDIT_USERNAME);
}, [openStep]);
const handleEditEmailClick = useCallback(() => {
openStep(StepTypes.EDIT_EMAIL);
}, [openStep]);
const handleEditPasswordClick = useCallback(() => {
openStep(StepTypes.EDIT_PASSWORD);
}, [openStep]);
const handleEditRoleClick = useCallback(() => {
openStep(StepTypes.EDIT_ROLE);
}, [openStep]);
const handleActivateClick = useCallback(() => {
openStep(StepTypes.ACTIVATE);
}, [openStep]);
const handleDeactivateClick = useCallback(() => {
openStep(StepTypes.DEACTIVATE);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_INFORMATION:
return <EditUserInformationStep id={userId} onBack={handleBack} onClose={onClose} />;
case StepTypes.EDIT_USERNAME:
return <EditUserUsernameStep id={userId} onBack={handleBack} onClose={onClose} />;
case StepTypes.EDIT_EMAIL:
return <EditUserEmailStep id={userId} onBack={handleBack} onClose={onClose} />;
case StepTypes.EDIT_PASSWORD:
return <EditUserPasswordStep id={userId} onBack={handleBack} onClose={onClose} />;
case StepTypes.EDIT_ROLE:
return (
<SelectRoleStep
withButton
defaultValue={user.role}
title="common.editRole"
buttonContent="action.save"
onSelect={handleRoleSelect}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.ACTIVATE:
return (
<ConfirmationStep
title="common.activateUser"
content="common.areYouSureYouWantToActivateThisUser"
buttonType="positive"
buttonContent="action.activateUser"
onConfirm={handleActivateConfirm}
onBack={handleBack}
/>
);
case StepTypes.DEACTIVATE:
return (
<ConfirmationStep
title="common.deactivateUser"
content="common.areYouSureYouWantToDeactivateThisUser"
buttonContent="action.deactivateUser"
onConfirm={handleDeactivateConfirm}
onBack={handleBack}
/>
);
case StepTypes.DELETE:
return (
<ConfirmationStep
title="common.deleteUser"
content="common.areYouSureYouWantToDeleteThisUser"
buttonContent="action.deleteUser"
typeValue={user.name}
typeContent="common.typeNameToConfirm"
onConfirm={handleDeleteConfirm}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header>
{t('common.userActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditInformationClick}>
{t('action.editInformation', {
context: 'title',
})}
</Menu.Item>
{!user.lockedFieldNames.includes('username') && (
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
{t('action.editUsername', {
context: 'title',
})}
</Menu.Item>
)}
{!user.lockedFieldNames.includes('email') && (
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
{t('action.editEmail', {
context: 'title',
})}
</Menu.Item>
)}
{!user.lockedFieldNames.includes('password') && (
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
{t('action.editPassword', {
context: 'title',
})}
</Menu.Item>
)}
{!user.lockedFieldNames.includes('role') && (
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
{t('action.editRole', {
context: 'title',
})}
</Menu.Item>
)}
<Menu.Item
disabled={
user.isDeactivated &&
activeUsersLimit !== null &&
activeUsersTotal >= activeUsersLimit
}
className={styles.menuItem}
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
>
{user.isDeactivated
? t('action.activateUser', {
context: 'title',
})
: t('action.deactivateUser', {
context: 'title',
})}
</Menu.Item>
{user.isDeactivated && !user.isDefaultAdmin && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteUser', {
context: 'title',
})}
</Menu.Item>
)}
</Menu>
</Popup.Content>
</>
);
});
ActionsStep.propTypes = {
userId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ActionsStep;

View file

@ -0,0 +1,16 @@
/*!
* 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: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
}

View file

@ -0,0 +1,256 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, 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, Form, Icon, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious } from '../../../../lib/hooks';
import { Input, Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useForm, useNestedRef, useSteps } from '../../../../hooks';
import { isPassword, isUsername } from '../../../../utils/validator';
import { UserRoles } from '../../../../constants/Enums';
import { UserRoleIcons } from '../../../../constants/Icons';
import SelectRoleStep from './SelectRoleStep';
import styles from './AddStep.module.scss';
const StepTypes = {
SELECT_ROLE: 'SELECT_ROLE',
};
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const AddStep = React.memo(({ onClose }) => {
const { data: defaultData, isSubmitting, error } = useSelector(selectors.selectUserCreateForm);
const dispatch = useDispatch();
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm(() => ({
email: '',
password: '',
name: '',
username: '',
role: UserRoles.BOARD_USER,
...defaultData,
}));
const [step, openStep, handleBack] = useSteps();
const message = useMemo(() => createMessage(error), [error]);
const [emailFieldRef, handleEmailFieldRef] = useNestedRef('inputRef');
const [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef');
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const [usernameFieldRef, handleUsernameFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
name: data.name.trim(),
username: data.username.trim() || null,
};
if (!isEmail(cleanData.email)) {
emailFieldRef.current.select();
return;
}
if (!cleanData.password || !isPassword(cleanData.password)) {
passwordFieldRef.current.focus();
return;
}
if (!cleanData.name) {
nameFieldRef.current.select();
return;
}
if (cleanData.username && !isUsername(cleanData.username)) {
usernameFieldRef.current.select();
return;
}
dispatch(entryActions.createUser(cleanData));
}, [dispatch, data, emailFieldRef, passwordFieldRef, nameFieldRef, usernameFieldRef]);
const handleRoleSelect = useCallback(
(role) => {
setData((prevData) => ({
...prevData,
role,
}));
},
[setData],
);
const handleMessageDismiss = useCallback(() => {
dispatch(entryActions.clearUserPasswordUpdateError());
}, [dispatch]);
const handleSelectRoleClick = useCallback(() => {
openStep(StepTypes.SELECT_ROLE);
}, [openStep]);
useEffect(() => {
emailFieldRef.current.focus({
preventScroll: true,
});
}, [emailFieldRef]);
useDidUpdate(() => {
if (wasSubmitting && !isSubmitting) {
if (error) {
switch (error.message) {
case 'Email already in use':
emailFieldRef.current.select();
break;
case 'Username already in use':
usernameFieldRef.current.select();
break;
default:
}
} else {
onClose();
}
}
}, [onClose, isSubmitting, wasSubmitting, error]);
if (step && step.type === StepTypes.SELECT_ROLE) {
return (
<SelectRoleStep defaultValue={data.role} onSelect={handleRoleSelect} onBack={handleBack} />
);
}
return (
<>
<Popup.Header>
{t('common.addUser', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={handleMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.email')}</div>
<Input
fluid
ref={handleEmailFieldRef}
name="email"
value={data.email}
maxLength={256}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.password')}</div>
<Input.Password
withStrengthBar
fluid
ref={handlePasswordFieldRef}
name="password"
value={data.password}
maxLength={256}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.name')}</div>
<Input
fluid
ref={handleNameFieldRef}
name="name"
value={data.name}
maxLength={128}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>
{t('common.username')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Input
fluid
ref={handleUsernameFieldRef}
name="username"
value={data.username}
maxLength={16}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.controls}>
<Button
positive
content={t('action.addUser')}
loading={isSubmitting}
disabled={isSubmitting}
className={styles.button}
/>
<Button
type="button"
className={classNames(styles.button, styles.selectRoleButton)}
onClick={handleSelectRoleClick}
>
<Icon name={UserRoleIcons[data.role]} className={styles.selectRoleButtonIcon} />
{t(`common.${data.role}`)}
</Button>
</div>
</Form>
</Popup.Content>
</>
);
});
AddStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default AddStep;

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
*/
:global(#app) {
.button {
white-space: nowrap;
}
.controls {
display: flex;
max-width: 280px;
@media only screen and (width < 768px) {
max-width: 226px;
}
}
.field {
margin-bottom: 8px;
}
.selectRoleButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-left: auto;
margin-right: 0;
overflow: hidden;
text-align: left;
text-decoration: underline;
text-overflow: ellipsis;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.selectRoleButtonIcon {
text-decoration: none;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View file

@ -0,0 +1,57 @@
/*!
* 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 { Button, Icon, Table } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import { usePopupInClosableContext } from '../../../../hooks';
import { UserRoleIcons } from '../../../../constants/Icons';
import ActionsStep from './ActionsStep';
import UserAvatar from '../../../users/UserAvatar';
import styles from './Item.module.scss';
const Item = React.memo(({ id }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const user = useSelector((state) => selectUserById(state, id));
const [t] = useTranslation();
const ActionsPopup = usePopupInClosableContext(ActionsStep);
return (
<Table.Row className={classNames(user.isDeactivated && styles.wrapperDeactivated)}>
<Table.Cell>
<UserAvatar id={id} />
</Table.Cell>
<Table.Cell>{user.name}</Table.Cell>
<Table.Cell>{user.username || '-'}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell className={styles.roleCell}>
<Icon name={UserRoleIcons[user.role]} className={styles.roleIcon} />
{t(`common.${user.role}`)}
</Table.Cell>
<Table.Cell textAlign="right">
<ActionsPopup userId={id}>
<Button className={styles.button}>
<Icon fitted name="pencil" />
</Button>
</ActionsPopup>
</Table.Cell>
</Table.Row>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
};
export default Item;

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) {
.button {
box-shadow: 0 1px 0 #cbcccc;
margin-right: 0;
}
.roleCell {
white-space: nowrap;
}
.roleIcon {
margin: 0 0.35714286em 0 0;
}
.wrapperDeactivated {
opacity: 0.64;
}
}

View file

@ -0,0 +1,100 @@
/*!
* 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 { useTranslation } from 'react-i18next';
import { Button, Form, Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui';
import { UserRoles } from '../../../../constants/Enums';
import { UserRoleIcons } from '../../../../constants/Icons';
import styles from './SelectRoleStep.module.scss';
const DESCRIPTION_BY_ROLE = {
[UserRoles.ADMIN]: 'common.canManageSystemWideSettingsAndActAsProjectOwner',
[UserRoles.PROJECT_OWNER]: 'common.canCreateOwnProjectsAndBeInvitedToWorkInOthers',
[UserRoles.BOARD_USER]: 'common.canBeInvitedToWorkInBoards',
};
const SelectRoleStep = React.memo(
({ defaultValue, title, withButton, buttonContent, onSelect, onBack, onClose }) => {
const [t] = useTranslation();
const [value, setValue] = useState(defaultValue);
const handleSelectClick = useCallback(
(_, { value: nextValue }) => {
if (withButton) {
setValue(nextValue);
} else {
if (nextValue !== defaultValue) {
onSelect(nextValue);
}
onBack();
}
},
[defaultValue, withButton, onSelect, onBack],
);
const handleSubmit = useCallback(() => {
if (value !== defaultValue) {
onSelect(value);
}
onClose();
}, [defaultValue, onSelect, onClose, value]);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Menu secondary vertical className={styles.menu}>
{[UserRoles.ADMIN, UserRoles.PROJECT_OWNER, UserRoles.BOARD_USER].map((role) => (
<Menu.Item
key={role}
value={role}
active={role === value}
className={styles.menuItem}
onClick={handleSelectClick}
>
<Icon name={UserRoleIcons[role]} className={styles.menuItemIcon} />
<div className={styles.menuItemTitle}>{t(`common.${role}`)}</div>
<p className={styles.menuItemDescription}>{t(DESCRIPTION_BY_ROLE[role])}</p>
</Menu.Item>
))}
</Menu>
{withButton && <Button positive content={t(buttonContent)} />}
</Form>
</Popup.Content>
</>
);
},
);
SelectRoleStep.propTypes = {
defaultValue: PropTypes.string.isRequired,
title: PropTypes.string,
withButton: PropTypes.bool,
buttonContent: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
SelectRoleStep.defaultProps = {
title: 'common.selectRole',
withButton: false,
buttonContent: 'action.selectRole',
onBack: undefined,
};
export default SelectRoleStep;

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,126 @@
/*!
* 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, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Tab, Table } from 'semantic-ui-react';
import { Input } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import { useField, useNestedRef, usePopupInClosableContext } from '../../../../hooks';
import Item from './Item';
import AddStep from './AddStep';
import styles from './UsersPane.module.scss';
const UsersPane = React.memo(() => {
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
const users = useSelector(selectors.selectUsersExceptCurrent);
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
const canAdd = useSelector((state) => {
const oidcConfig = selectors.selectOidcConfig(state);
return !oidcConfig || !oidcConfig.isEnforced;
});
const [t] = useTranslation();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const [isDeactivatedVisible, setIsDeactivatedVisible] = useState(false); // TODO: refactor?
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
const filteredUsers = useMemo(
() =>
users.filter((user) => {
if (isDeactivatedVisible) {
if (!user.isDeactivated) {
return false;
}
} else if (user.isDeactivated) {
return false;
}
return (
user.email.includes(cleanSearch) ||
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch))
);
}),
[users, isDeactivatedVisible, cleanSearch],
);
const handleToggleDeactivatedClick = useCallback(() => {
setIsDeactivatedVisible(!isDeactivatedVisible);
}, [isDeactivatedVisible]);
useEffect(() => {
searchFieldRef.current.focus();
}, [searchFieldRef]);
const AddPopup = usePopupInClosableContext(AddStep);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Input
fluid
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchUsers')}
maxLength={256}
icon="search"
onChange={handleSearchChange}
/>
<Divider />
<div className={styles.tableWrapper}>
<Table unstackable basic="very">
<Table.Header>
<Table.Row>
<Table.HeaderCell />
<Table.HeaderCell width={4}>{t('common.name')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.username')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.email')}</Table.HeaderCell>
<Table.HeaderCell>{t('common.role')}</Table.HeaderCell>
<Table.HeaderCell />
</Table.Row>
</Table.Header>
<Table.Body>
{filteredUsers.map((user) => (
<Item key={user.id} id={user.id} />
))}
</Table.Body>
</Table>
</div>
<div className={styles.actions}>
<Button
content={isDeactivatedVisible ? t('action.showActive') : t('action.showDeactivated')}
className={styles.toggleDeactivatedButton}
onClick={handleToggleDeactivatedClick}
/>
{canAdd && (
<AddPopup>
<Button
positive
disabled={activeUsersLimit !== null && activeUsersTotal >= activeUsersLimit}
className={styles.addButton}
>
{t('action.addUser')}
{activeUsersLimit !== null && (
<span className={styles.addButtonCounter}>
{activeUsersTotal}/{activeUsersLimit}
</span>
)}
</Button>
</AddPopup>
)}
</div>
</Tab.Pane>
);
});
export default UsersPane;

View file

@ -0,0 +1,81 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.actions {
border-top: 1px solid rgba(34, 36, 38, 0.15);
padding-top: 1rem;
text-align: right;
&:after {
clear: both;
content: "";
display: table;
}
}
.addButton {
margin-right: 0;
}
.addButtonCounter {
margin-left: 6px;
opacity: 0.5;
}
.tableWrapper {
margin-right: -2.5rem;
max-height: calc(100vh - 338px);
overflow: auto;
padding-right: 2.5rem;
@media (width < 768px) {
margin-right: -2rem;
max-height: calc(100vh - 296px);
padding-right: 2rem;
}
@media (768px <= width < 926px) {
max-height: calc(100vh - 310px);
}
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
.toggleDeactivatedButton {
background: transparent;
box-shadow: none;
color: #6b808c;
float: left;
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(9, 30, 66, 0.08);
color: #092d42;
}
}
.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 UsersPane from './UsersPane';
export default UsersPane;

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

View file

@ -0,0 +1,108 @@
/*!
* 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 { Button, Form, Input } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import { useForm, useNestedRef } from '../../../hooks';
import styles from './ConfirmationStep.module.scss';
const ButtonTypes = {
POSITIVE: 'positive',
NEGATIVE: 'negative',
};
const ConfirmationStep = React.memo(
({ title, content, buttonType, buttonContent, typeValue, typeContent, onConfirm, onBack }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm({
typeValue: '',
});
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
if (typeValue) {
const cleanData = {
...data,
typeValue: data.typeValue.trim(),
};
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
nameFieldRef.current.select();
return;
}
}
onConfirm();
}, [typeValue, onConfirm, data, nameFieldRef]);
useEffect(() => {
if (typeValue) {
nameFieldRef.current.select();
}
}, [typeValue, nameFieldRef]);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.content}>{t(content)}</div>
{typeContent && <div className={styles.content}>{t(typeContent)}</div>}
<Form onSubmit={handleSubmit}>
{typeValue && (
<Input
fluid
ref={handleNameFieldRef}
name="typeValue"
value={data.typeValue}
placeholder={typeValue}
maxLength={128}
className={styles.field}
onChange={handleFieldChange}
/>
)}
<Button
{...{
[buttonType]: true,
}}
fluid
content={t(buttonContent)}
/>
</Form>
</Popup.Content>
</>
);
},
);
ConfirmationStep.propTypes = {
title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
buttonType: PropTypes.oneOf(Object.values(ButtonTypes)),
buttonContent: PropTypes.string.isRequired,
typeValue: PropTypes.string,
typeContent: PropTypes.string,
onConfirm: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
ConfirmationStep.defaultProps = {
buttonType: ButtonTypes.NEGATIVE,
typeValue: undefined,
typeContent: undefined,
onBack: undefined,
};
export default ConfirmationStep;

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) {
.content {
color: #212121;
padding-bottom: 6px;
}
.field {
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 ConfirmationStep from './ConfirmationStep';
export default ConfirmationStep;

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
*/
import React, { useCallback, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation, Trans } from 'react-i18next';
import { Loader } from 'semantic-ui-react';
import selectors from '../../../selectors';
import version from '../../../version';
import ModalTypes from '../../../constants/ModalTypes';
import Message from './Message';
import Toaster from '../Toaster';
import Fixed from '../Fixed';
import Static from '../Static';
import AdministrationModal from '../AdministrationModal';
import UserSettingsModal from '../../users/UserSettingsModal';
import ProjectBackground from '../../projects/ProjectBackground';
import AddProjectModal from '../../projects/AddProjectModal';
const Core = React.memo(() => {
const isInitializing = useSelector(selectors.selectIsInitializing);
const isSocketDisconnected = useSelector(selectors.selectIsSocketDisconnected);
const modal = useSelector(selectors.selectCurrentModal);
const project = useSelector(selectors.selectCurrentProject);
const board = useSelector(selectors.selectCurrentBoard);
// TODO: move to selector?
const isNewVersionAvailable = useSelector((state) => {
const config = selectors.selectConfig(state);
return !!config && config.version !== version;
});
const [t] = useTranslation();
const defaultTitleRef = useRef(document.title);
const handleRefreshPageClick = useCallback(() => {
window.location.reload(true);
}, []);
useEffect(() => {
const titleParts = [];
if (project) {
if (board) {
titleParts.push(board.name);
}
titleParts.push(project.name);
}
document.title = titleParts.length === 0 ? defaultTitleRef.current : titleParts.join(' | ');
}, [project, board]);
let modalNode = null;
if (modal) {
switch (modal.type) {
case ModalTypes.ADMINISTRATION:
modalNode = <AdministrationModal />;
break;
case ModalTypes.USER_SETTINGS:
modalNode = <UserSettingsModal />;
break;
case ModalTypes.ADD_PROJECT:
modalNode = <AddProjectModal />;
break;
default:
}
}
let messageNode = null;
if (isSocketDisconnected) {
messageNode = (
<Message
type="error"
header={t('common.noConnectionToServer')}
content={
<Trans i18nKey="common.allChangesWillBeAutomaticallySavedAfterConnectionRestored">
All changes will be automatically saved
<br />
after connection restored
</Trans>
}
/>
);
} else if (isNewVersionAvailable) {
messageNode = (
<Message
type="info"
header={t('common.newVersionAvailable')}
content={
<Trans i18nKey="common.clickHereOrRefreshPageToUpdate">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid,
jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<a onClick={handleRefreshPageClick}>Click here</a> or refresh the page to update
</Trans>
}
/>
);
}
return (
<>
{isInitializing ? (
<Loader active size="massive" />
) : (
<>
<Toaster />
{project && project.backgroundType && <ProjectBackground />}
<Fixed />
<Static />
{modalNode}
</>
)}
{messageNode}
</>
);
});
export default Core;

View file

@ -0,0 +1,31 @@
/*!
* 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 React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Message.module.scss';
const Types = {
INFO: 'info',
ERROR: 'error',
};
const Message = React.memo(({ type, header, content }) => (
<div className={classNames(styles.wrapper, styles[`wrapper${upperFirst(type)}`])}>
<div className={styles.header}>{header}</div>
<div className={styles.content}>{content}</div>
</div>
));
Message.propTypes = {
type: PropTypes.oneOf(Object.values(Types)).isRequired,
header: PropTypes.node.isRequired,
content: PropTypes.node.isRequired,
};
export default Message;

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) {
.content {
color: #fff;
font-size: 16px;
line-height: 1.4;
a {
color: #fff;
cursor: pointer;
text-decoration: underline;
}
}
.header {
color: #fff;
font-size: 24px;
font-weight: bold;
line-height: 1.2;
margin-bottom: 8px;
}
.wrapper {
border-radius: 4px;
bottom: 20px;
max-width: calc(100% - 40px);
padding: 12px 18px;
position: fixed;
right: 20px;
width: 390px;
z-index: 10001;
}
.wrapperError {
background: #cf513d;
box-shadow: #6e2f1a 0 1px 0;
}
.wrapperInfo {
background: #2185d0;
box-shadow: rgba(34, 36, 38, 0.15) 0 1px 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 Core from './Core';
export default Core;

View file

@ -0,0 +1,136 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useRef, useState } 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 { useClickAwayListener } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks';
import MarkdownEditor from '../MarkdownEditor';
import styles from './EditMarkdown.module.scss';
const MAX_LENGTH = 1048576;
const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }) => {
const defaultMode = useSelector((state) => selectors.selectCurrentUser(state).defaultEditorMode);
const dispatch = useDispatch();
const [t] = useTranslation();
const [value, setValue] = useState(() => draftValue || defaultValue || '');
const fieldRef = useRef(null);
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
const [cancelButtonRef, handleCancelButtonRef] = useNestedRef();
const handleModeChange = useCallback(
(mode) => {
dispatch(
entryActions.updateCurrentUser({
defaultEditorMode: mode,
}),
);
},
[dispatch],
);
const isExceeded = value.length > MAX_LENGTH;
const submit = useCallback(() => {
const cleanValue = value.trim() || null;
if (!isExceeded && cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
onClose(isExceeded ? cleanValue : null);
}, [onUpdate, onClose, defaultValue, value, isExceeded]);
const handleChange = useCallback((nextValue) => {
setValue(nextValue);
}, []);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
const handleCancel = useCallback(() => {
submit();
}, [submit]);
const handleCancelClick = useCallback(() => {
onClose(null);
}, [onClose]);
const handleClickAwayCancel = useCallback(() => {
fieldRef.current.focus();
}, [fieldRef]);
const clickAwayProps = useClickAwayListener(
[fieldRef, submitButtonRef, cancelButtonRef],
submit,
handleClickAwayCancel,
);
return (
<>
<MarkdownEditor
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={fieldRef}
defaultValue={value}
defaultMode={defaultMode}
isError={isExceeded}
onChange={handleChange}
onSubmit={handleSubmit}
onCancel={handleCancel}
onModeChange={handleModeChange}
/>
<Form onSubmit={handleSubmit}>
<div className={styles.controls}>
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
positive
ref={handleSubmitButtonRef}
content={
isExceeded
? t('common.contentExceedsLimit', {
limit: '1MB',
})
: t('action.save')
}
disabled={isExceeded}
/>
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleCancelButtonRef}
type="button"
content={t('action.cancel')}
onClick={handleCancelClick}
/>
</div>
</Form>
</>
);
});
EditMarkdown.propTypes = {
defaultValue: PropTypes.string,
draftValue: PropTypes.string,
// placeholder: PropTypes.string.isRequired, // TODO: remove?
onUpdate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
EditMarkdown.defaultProps = {
defaultValue: undefined,
draftValue: undefined,
};
export default EditMarkdown;

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) {
.controls {
clear: both;
margin-top: 6px;
}
}

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

View file

@ -0,0 +1,49 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useRef } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import selectors from '../../../selectors';
import ProjectCard from '../../projects/ProjectCard';
import styles from './Favorites.module.scss';
const Favorites = React.memo(() => {
const { projectId: currentProjectId } = useSelector(selectors.selectPath);
const projectIds = useSelector(selectors.selectFavoriteProjectIdsForCurrentUser);
const isActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
const cardsRef = useRef(null);
const handleWheel = useCallback(({ deltaY }) => {
cardsRef.current.scrollBy({
left: deltaY,
});
}, []);
return (
<div
className={classNames(styles.wrapper, isActive && styles.wrapperActive)}
onWheel={handleWheel}
>
<div ref={cardsRef} className={styles.cards}>
{projectIds.map((projectId) => (
<div key={projectId} className={styles.cardWrapper}>
<ProjectCard
id={projectId}
size="small"
isActive={projectId === currentProjectId}
className={styles.card}
/>
</div>
))}
</div>
</div>
);
});
export default Favorites;

View file

@ -0,0 +1,55 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.card {
height: 70px;
width: 160px;
}
.cardWrapper:not(:last-child) {
margin-right: 20px;
}
.cards {
display: flex;
height: 108px;
overflow-x: auto;
overflow-y: hidden;
padding: 10px 20px 0 20px;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&:hover {
height: 90px;
}
&::-webkit-scrollbar {
height: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
.wrapper {
background: rgba(0, 0, 0, 0.16);
height: 0;
overflow-y: hidden;
transition: height 0.2s ease;
}
.wrapperActive {
height: 90px;
}
}

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

View file

@ -0,0 +1,33 @@
/*!
* 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 Header from '../Header';
import Favorites from '../Favorites';
import HomeActions from '../HomeActions';
import Project from '../../projects/Project';
import BoardActions from '../../boards/BoardActions';
import styles from './Fixed.module.scss';
const Fixed = React.memo(() => {
const { projectId } = useSelector(selectors.selectPath);
const board = useSelector(selectors.selectCurrentBoard);
return (
<div className={styles.wrapper}>
<Header />
<Favorites />
{projectId === undefined && <HomeActions />}
{projectId && <Project />}
{board && !board.isFetching && <BoardActions />}
</div>
);
});
export default Fixed;

View file

@ -0,0 +1,12 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
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 Fixed from './Fixed';
export default Fixed;

View file

@ -0,0 +1,167 @@
/*!
* 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 classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Button, Icon, Menu } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import Paths from '../../../constants/Paths';
import { BoardMembershipRoles, BoardViews, UserRoles } from '../../../constants/Enums';
import UserAvatar from '../../users/UserAvatar';
import UserStep from '../../users/UserStep';
import NotificationsStep from '../../notifications/NotificationsStep';
import styles from './Header.module.scss';
const POPUP_PROPS = {
position: 'bottom right',
};
const Header = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
const project = useSelector(selectors.selectCurrentProject);
const board = useSelector(selectors.selectCurrentBoard);
const notificationIds = useSelector(selectors.selectNotificationIdsForCurrentUser);
const isFavoritesEnabled = useSelector(selectors.selectIsFavoritesEnabled);
const isEditModeEnabled = useSelector(selectors.selectIsEditModeEnabled);
const withFavoritesToggler = useSelector(
// TODO: use selector instead?
(state) => selectors.selectFavoriteProjectIdsForCurrentUser(state).length > 0,
);
const { withEditModeToggler, canEditProject } = useSelector((state) => {
if (!project) {
return {
withEditModeToggler: false,
canEditProject: false,
};
}
const isAdminInSharedProject = user.role === UserRoles.ADMIN && !project.ownerProjectManagerId;
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
if (isAdminInSharedProject || isManager) {
return {
withEditModeToggler: true,
canEditProject: isEditModeEnabled,
};
}
if (!board) {
return {
withEditModeToggler: false,
canEditProject: false,
};
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
return {
withEditModeToggler: board.view === BoardViews.KANBAN && isEditor,
canEditProject: false,
};
}, shallowEqual);
const dispatch = useDispatch();
const handleToggleEditModeClick = useCallback(() => {
dispatch(entryActions.toggleEditMode(!isEditModeEnabled));
}, [isEditModeEnabled, dispatch]);
const handleToggleFavoritesClick = useCallback(() => {
dispatch(entryActions.toggleFavorites(!isFavoritesEnabled));
}, [isFavoritesEnabled, dispatch]);
const handleProjectSettingsClick = useCallback(() => {
if (!canEditProject) {
return;
}
dispatch(entryActions.openProjectSettingsModal());
}, [canEditProject, dispatch]);
const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS);
const UserPopup = usePopup(UserStep, POPUP_PROPS);
return (
<div className={styles.wrapper}>
{!project && (
<Link to={Paths.ROOT} className={classNames(styles.logo, styles.title)}>
PLANKA
</Link>
)}
<Menu inverted size="large" className={styles.menu}>
{project && (
<Menu.Menu position="left">
<Menu.Item
as={Link}
to={Paths.ROOT}
className={classNames(styles.item, styles.itemHoverable)}
>
<Icon fitted name="arrow left" />
</Menu.Item>
<Menu.Item className={classNames(styles.item, styles.title)}>
{project.name}
{canEditProject && (
<Button className={styles.editButton} onClick={handleProjectSettingsClick}>
<Icon fitted name="pencil" size="small" />
</Button>
)}
</Menu.Item>
</Menu.Menu>
)}
<Menu.Menu position="right">
{withFavoritesToggler && (
<Menu.Item
className={classNames(styles.item, styles.itemHoverable)}
onClick={handleToggleFavoritesClick}
>
<Icon
fitted
name={isFavoritesEnabled ? 'star' : 'star outline'}
className={classNames(isFavoritesEnabled && styles.itemIconEnabled)}
/>
</Menu.Item>
)}
{withEditModeToggler && (
<Menu.Item
className={classNames(styles.item, styles.itemHoverable)}
onClick={handleToggleEditModeClick}
>
<Icon
fitted
name={isEditModeEnabled ? 'unlock' : 'lock'}
className={classNames(isEditModeEnabled && styles.itemIconEnabled)}
/>
</Menu.Item>
)}
<NotificationsPopup>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
<Icon fitted name="bell" />
{notificationIds.length > 0 && (
<span className={styles.notification}>{notificationIds.length}</span>
)}
</Menu.Item>
</NotificationsPopup>
<UserPopup>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
<span className={styles.userName}>{user.name}</span>
<UserAvatar id={user.id} size="small" />
</Menu.Item>
</UserPopup>
</Menu.Menu>
</Menu>
</div>
);
});
export default Header;

View file

@ -0,0 +1,110 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.editButton {
background: transparent;
box-shadow: none;
color: #fff;
font-size: 15px;
line-height: 34px;
margin-left: 8px;
opacity: 0;
padding: 0;
width: 34px;
&:hover {
background: rgba(255, 255, 255, 0.08);
}
}
.item {
cursor: auto;
user-select: auto;
&:before {
background: none;
}
&:active,
&:hover {
background: transparent;
color: rgba(255, 255, 255, 0.9);
.editButton {
opacity: 1;
}
}
}
.itemHoverable:hover {
cursor: pointer;
background: rgba(0, 0, 0, 0.32);
}
.itemIconEnabled {
color: #bdff22;
}
.logo {
color: #fff;
flex: 0 0 auto;
letter-spacing: 3.5px;
line-height: 50px;
padding: 0 16px;
text-transform: uppercase;
&:before {
background: none;
}
}
.menu {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
color: #fff;
flex: 1 1 auto;
height: 50px;
margin: 0;
width: 100%;
}
.notification {
background: #eb5a46;
border-radius: 8px;
color: #fff;
display: inline-block;
font-size: 14px;
font-weight: bold;
height: 16px;
left: 22px;
line-height: 16px;
min-width: 16px;
position: absolute;
text-align: center;
top: 8px;
}
.title {
font-size: 20px;
font-weight: bold;
}
.userName {
margin-right: 10px;
@media only screen and (width < 768px) {
display: none;
}
}
.wrapper {
background: rgba(0, 0, 0, 0.24);
display: flex;
flex: 0 0 auto;
}
}

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

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
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import Projects from './Projects';
const GridProjectsView = React.memo(() => {
const projectIds = useSelector(selectors.selectFilteredProjectIdsForCurrentUser);
const dispatch = useDispatch();
const handleAdd = useCallback(() => {
dispatch(entryActions.openAddProjectModal());
}, [dispatch]);
return <Projects ids={projectIds} onAdd={handleAdd} />;
});
export default GridProjectsView;

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 } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { isUserAdminOrProjectOwner } from '../../../utils/record-helpers';
import { ProjectGroups, ProjectTypes } from '../../../constants/Enums';
import { ProjectGroupIcons } from '../../../constants/Icons';
import Projects from './Projects';
const TITLE_BY_GROUP = {
[ProjectGroups.MY_OWN]: 'common.myOwn',
[ProjectGroups.TEAM]: 'common.team',
[ProjectGroups.SHARED_WITH_ME]: 'common.sharedWithMe',
[ProjectGroups.OTHERS]: 'common.others',
};
const DEFAULT_TYPE_BY_GROUP = {
[ProjectGroups.MY_OWN]: ProjectTypes.PRIVATE,
[ProjectGroups.TEAM]: ProjectTypes.SHARED,
};
const GroupedProjectsView = React.memo(() => {
const projectIdsByGroup = useSelector(selectors.selectFilteredProjctIdsByGroupForCurrentUser);
const canAdd = useSelector((state) => {
const user = selectors.selectCurrentUser(state);
return isUserAdminOrProjectOwner(user);
});
const dispatch = useDispatch();
const handleAdd = useCallback(
(defaultType) => {
dispatch(entryActions.openAddProjectModal(defaultType));
},
[dispatch],
);
return (
<>
{[ProjectGroups.MY_OWN, ProjectGroups.TEAM].map(
(group) =>
(projectIdsByGroup[group].length > 0 || canAdd) && (
<Projects
key={group}
ids={projectIdsByGroup[group]}
title={TITLE_BY_GROUP[group]}
titleIcon={ProjectGroupIcons[group]}
onAdd={() => handleAdd(DEFAULT_TYPE_BY_GROUP[group])}
/>
),
)}
{[ProjectGroups.SHARED_WITH_ME, ProjectGroups.OTHERS].map(
(group) =>
projectIdsByGroup[group].length > 0 && (
<Projects
withTypeIndicator
key={group}
ids={projectIdsByGroup[group]}
title={TITLE_BY_GROUP[group]}
titleIcon={ProjectGroupIcons[group]}
/>
),
)}
</>
);
});
export default GroupedProjectsView;

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
*/
import React from 'react';
import { useSelector } from 'react-redux';
import selectors from '../../../selectors';
import { HomeViews } from '../../../constants/Enums';
import GridProjectsView from './GridProjectsView';
import GroupedProjectsView from './GroupedProjectsView';
import styles from './Home.module.scss';
const Home = React.memo(() => {
const view = useSelector(selectors.selectHomeView);
let View;
switch (view) {
case HomeViews.GRID_PROJECTS:
View = GridProjectsView;
break;
case HomeViews.GROUPED_PROJECTS:
View = GroupedProjectsView;
break;
default:
}
return (
<div className={styles.wrapper}>
<View />
</div>
);
});
export default Home;

View file

@ -0,0 +1,18 @@
/*!
* 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%;
margin-left: auto !important;
margin-right: auto !important;
overflow-y: auto;
padding: 0 20px;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) rgba(0, 0, 0, 0.08);
}
}
}

View file

@ -0,0 +1,87 @@
/*!
* 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 PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Grid, Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { isUserAdminOrProjectOwner } from '../../../utils/record-helpers';
import ProjectCard from '../../projects/ProjectCard';
import PlusIcon from '../../../assets/images/plus-icon.svg?react';
import styles from './Projects.module.scss';
const Projects = React.memo(({ ids, title, titleIcon, withTypeIndicator, onAdd }) => {
const canAdd = useSelector((state) => {
const user = selectors.selectCurrentUser(state);
return isUserAdminOrProjectOwner(user);
});
const [t] = useTranslation();
return (
<div className={classNames(styles.wrapper, !title && styles.wrapperWithoutTitle)}>
{title && (
<div className={styles.title}>
{titleIcon && <Icon name={titleIcon} className={styles.titleIcon} />}
{t(title, {
context: 'title',
})}
</div>
)}
<Grid>
{ids.map((id) => (
<Grid.Column key={id} className={styles.column}>
<ProjectCard
withDescription
withFavoriteButton
id={id}
withTypeIndicator={withTypeIndicator}
className={styles.card}
/>
</Grid.Column>
))}
{onAdd && canAdd && (
<Grid.Column className={styles.column}>
<button
type="button"
className={classNames(styles.card, styles.addButton)}
onClick={onAdd}
>
<div className={styles.addButtonCover} />
<div className={styles.addButtonTitleWrapper}>
<div className={styles.addButtonTitle}>
<PlusIcon className={styles.addButtonTitleIcon} />
{t('action.createProject')}
</div>
</div>
</button>
</Grid.Column>
)}
</Grid>
</div>
);
});
Projects.propTypes = {
ids: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
titleIcon: PropTypes.string,
withTypeIndicator: PropTypes.bool, // TODO: use plural form?
onAdd: PropTypes.func,
};
Projects.defaultProps = {
title: undefined,
titleIcon: undefined,
withTypeIndicator: false,
onAdd: undefined,
};
export default Projects;

View file

@ -0,0 +1,115 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.addButton {
background: transparent;
border: none;
border-radius: 4px;
color: rgba(255, 255, 255, 0.48);
cursor: pointer;
fill: rgba(255, 255, 255, 0.48);
overflow: hidden;
padding: 0;
position: relative;
text-align: center;
width: 100%;
&:hover {
.addButtonCover {
filter: brightness(0.8);
}
}
}
.addButtonCover {
background: rgba(107, 128, 140, 0.08);
bottom: 0;
content: "";
left: 0;
position: absolute;
right: 0;
top: 0;
transition: filter 0.2s ease;
}
.addButtonTitle {
display: table-cell;
font-size: 16px;
line-height: 1;
vertical-align: middle;
}
.addButtonTitleIcon {
display: block;
margin: 0 auto 12px;
width: 36px;
}
.addButtonTitleWrapper {
display: table;
height: 100%;
padding: 35px 21px;
width: 100%;
}
.card {
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1), 0 6px 12px rgba(0, 0, 0, 0.1);
height: 150px;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.1), 0 32px 64px rgba(0, 0, 0, 0.1);
}
}
.column {
@media only screen and (width < 840px) {
width: 50% !important;
}
@media only screen and (840px <= width < 1120px) {
width: 33.3333333333% !important;
}
@media only screen and (1120px <= width < 1400px) {
width: 25% !important;
}
@media only screen and (1400px <= width < 1680px) {
width: 20% !important;
}
@media only screen and (1680px <= width < 1960px) {
width: 16.6666666667% !important;
}
@media only screen and (1960px <= width < 2240px) {
width: 14.2857142857% !important;
}
@media only screen and (width >= 2240px) {
width: 12.5% !important;
}
}
.title {
color: #6b808c;
font-size: 16px;
padding: 14px 0 14px 2px;
}
.titleIcon {
margin-right: 10px;
}
.wrapper:not(:last-child) {
margin-bottom: 20px;
}
.wrapperWithoutTitle {
margin-top: 14px;
}
}

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

View file

@ -0,0 +1,99 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import debounce from 'lodash/debounce';
import React, { useCallback, useMemo, useState } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../lib/hooks';
import { Input } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks';
import styles from './Filters.module.scss';
const Filters = React.memo(() => {
const defaultSearch = useSelector(selectors.selectProjectsSearch);
const dispatch = useDispatch();
const [t] = useTranslation();
const [search, setSearch] = useState(defaultSearch);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const debouncedSearch = useMemo(
() =>
debounce((nextSearch) => {
dispatch(entryActions.searchProjects(nextSearch));
}, 400),
[dispatch],
);
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
const cancelSearch = useCallback(() => {
debouncedSearch.cancel();
setSearch('');
dispatch(entryActions.searchProjects(''));
searchFieldRef.current.blur();
}, [dispatch, debouncedSearch, searchFieldRef]);
const handleSearchChange = useCallback(
(_, { value }) => {
setSearch(value);
debouncedSearch(value);
},
[debouncedSearch],
);
const handleSearchFocus = useCallback(() => {
setIsSearchFocused(true);
}, []);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
cancelSearch();
}
},
[cancelSearch],
);
const handleSearchBlur = useCallback(() => {
setIsSearchFocused(false);
}, []);
const handleCancelSearchClick = useCallback(() => {
cancelSearch();
}, [cancelSearch]);
useDidUpdate(() => {
setSearch(defaultSearch);
}, [defaultSearch]);
const isSearchActive = search || isSearchFocused;
return (
<Input
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchProjects')}
maxLength={128}
icon={
isSearchActive ? <Icon link name="cancel" onClick={handleCancelSearchClick} /> : 'search'
}
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
onFocus={handleSearchFocus}
onKeyDown={handleSearchKeyDown}
onChange={handleSearchChange}
onBlur={handleSearchBlur}
/>
);
});
export default Filters;

View file

@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.search {
height: 36px;
transition: max-width 0.2s ease;
max-width: 500px;
width: 100%;
input {
font-size: 13px;
}
}
.searchInactive {
color: rgba(255, 255, 255, 0.72);
max-width: 400px;
input {
background: rgba(0, 0, 0, 0.24);
border: none;
color: rgba(255, 255, 255, 0.72) !important;
&::placeholder {
color: rgba(255, 255, 255, 0.72);
}
}
}
}

View file

@ -0,0 +1,29 @@
/*!
* 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 Filters from './Filters';
import RightSide from './RightSide';
import styles from './HomeActions.module.scss';
const HomeActions = React.memo(() => (
<div className={styles.wrapper}>
<div className={styles.content}>
<div className={styles.actions}>
<div className={classNames(styles.action, styles.actionFilters)}>
<Filters />
</div>
<div className={classNames(styles.action, styles.actionRightSide)}>
<RightSide />
</div>
</div>
</div>
</div>
));
export default HomeActions;

View file

@ -0,0 +1,54 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.action {
align-items: center;
display: flex;
flex: 0 0 auto;
&:first-child {
padding-left: 20px;
}
&:last-child {
padding-right: 20px;
}
&:not(:last-child) {
margin-right: 20px;
}
}
.actionFilters {
flex-grow: 1;
}
.actionRightSide {
margin-left: auto;
}
.actions {
display: flex;
}
.content {
width: 100%;
}
.wrapper {
display: flex;
justify-content: center;
overflow-x: auto;
overflow-y: hidden;
-ms-overflow-style: none;
padding: 15px 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}

View file

@ -0,0 +1,85 @@
/*!
* 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 classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { Icon } from 'semantic-ui-react';
import { usePopup } from '../../../../lib/popup';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { HomeViews } from '../../../../constants/Enums';
import { HomeViewIcons, ProjectOrderIcons } from '../../../../constants/Icons';
import SelectOrderStep from './SelectOrderStep';
import styles from './RightSide.module.scss';
const RightSide = React.memo(() => {
const currentView = useSelector(selectors.selectHomeView); // TODO: rename?
const currentOrder = useSelector(selectors.selectProjectsOrder); // TODO: rename?
const isHiddenVisible = useSelector(selectors.selectIsHiddenProjectsVisible);
const dispatch = useDispatch();
const handleSelectViewClick = useCallback(
({ currentTarget: { value: view } }) => {
dispatch(entryActions.updateHomeView(view));
},
[dispatch],
);
const handleOrderSelect = useCallback(
(order) => {
dispatch(entryActions.updateProjectsOrder(order));
},
[dispatch],
);
const handleToggleHiddenClick = useCallback(() => {
dispatch(entryActions.toggleHiddenProjects(!isHiddenVisible));
}, [isHiddenVisible, dispatch]);
const SelectOrderPopup = usePopup(SelectOrderStep);
return (
<>
<div className={styles.action}>
<button
type="button"
className={classNames(styles.button)}
onClick={handleToggleHiddenClick}
>
<Icon fitted name={isHiddenVisible ? 'eye slash' : 'eye'} />
</button>
</div>
<div className={styles.action}>
<SelectOrderPopup value={currentOrder} onSelect={handleOrderSelect}>
<button type="button" className={styles.button}>
<Icon fitted name={ProjectOrderIcons[currentOrder]} />
</button>
</SelectOrderPopup>
</div>
<div className={styles.action}>
<div className={styles.buttonGroup}>
{[HomeViews.GRID_PROJECTS, HomeViews.GROUPED_PROJECTS].map((view) => (
<button
key={view}
type="button"
value={view}
disabled={view === currentView}
className={styles.button}
onClick={handleSelectViewClick}
>
<Icon fitted name={HomeViewIcons[view]} />
</button>
))}
</div>
</div>
</>
);
});
export default RightSide;

View file

@ -0,0 +1,52 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.action:not(:last-child) {
margin-right: 10px;
}
.button {
background: rgba(0, 0, 0, 0.24);
border: none;
border-radius: 3px;
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
display: inline-block;
font-size: 13px;
line-height: 20px;
outline: none;
padding: 8px 15px;
width: 43px;
&:hover {
background: rgba(0, 0, 0, 0.32);
}
}
.buttonGroup {
.button {
border-radius: 0;
&:enabled {
background: rgba(0, 0, 0, 0.08);
&:hover {
background: rgba(0, 0, 0, 0.12);
}
}
&:first-child {
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
&:last-child {
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
}
}
}

View file

@ -0,0 +1,68 @@
/*!
* 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 { ProjectOrders } from '../../../../constants/Enums';
import { ProjectOrderIcons } from '../../../../constants/Icons';
import styles from './SelectOrderStep.module.scss';
const SelectOrderStep = 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.selectOrder', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{[
ProjectOrders.BY_DEFAULT,
ProjectOrders.ALPHABETICALLY,
ProjectOrders.BY_CREATION_TIME,
].map((order) => (
<Menu.Item
key={order}
value={order}
active={order === value}
className={styles.menuItem}
onClick={handleSelectClick}
>
<Icon name={ProjectOrderIcons[order]} className={styles.menuItemIcon} />
{t(`common.${order}`)}
</Menu.Item>
))}
</Menu>
</Popup.Content>
</>
);
});
SelectOrderStep.propTypes = {
value: PropTypes.string.isRequired,
onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default SelectOrderStep;

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) {
.menu {
margin: 0 auto;
width: 100%;
}
.menuItem:last-child {
margin-bottom: 0;
}
.menuItemIcon {
float: left;
margin: 0 8px 0 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 RightSide from './RightSide';
export default RightSide;

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

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 } from 'react';
import PropTypes from 'prop-types';
import LinkifyReact from 'linkify-react';
import history from '../../history';
const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => {
const handleLinkClick = useCallback(
(event) => {
if (linkStopPropagation) {
event.stopPropagation();
}
if (!event.target.getAttribute('target')) {
event.preventDefault();
history.push(event.target.href);
}
},
[linkStopPropagation],
);
const linkRenderer = useCallback(
({ attributes: { href, ...linkProps }, content }) => {
let url;
try {
url = new URL(href, window.location);
} catch {
/* empty */
}
const isSameSite = !!url && url.origin === window.location.origin;
return (
<a
{...linkProps} // eslint-disable-line react/jsx-props-no-spreading
href={href}
target={isSameSite ? undefined : '_blank'}
rel={isSameSite ? undefined : 'noreferrer'}
onClick={handleLinkClick}
>
{isSameSite ? url.pathname : content}
</a>
);
},
[handleLinkClick],
);
return (
<LinkifyReact
{...props} // eslint-disable-line react/jsx-props-no-spreading
options={{
defaultProtocol: 'https',
render: linkRenderer,
}}
>
{children}
</LinkifyReact>
);
});
Linkify.propTypes = {
children: PropTypes.string.isRequired,
linkStopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
linkStopPropagation: false,
};
export default Linkify;

View file

@ -0,0 +1,265 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Grid, Header, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { Input } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import { isUsername } from '../../../utils/validator';
import styles from './Content.module.scss';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Invalid credentials':
return {
type: 'error',
content: 'common.invalidCredentials',
};
case 'Invalid email or username':
return {
type: 'error',
content: 'common.invalidEmailOrUsername',
};
case 'Invalid password':
return {
type: 'error',
content: 'common.invalidPassword',
};
case 'Use single sign-on':
return {
type: 'error',
content: 'common.useSingleSignOn',
};
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
case 'Active users limit reached':
return {
type: 'error',
content: 'common.activeUsersLimitReached',
};
case 'Failed to fetch':
return {
type: 'warning',
content: 'common.noInternetConnection',
};
case 'Network request failed':
return {
type: 'warning',
content: 'common.serverConnectionFailed',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const Content = React.memo(() => {
const config = useSelector(selectors.selectConfig);
const {
data: defaultData,
isSubmitting,
isSubmittingWithOidc,
error,
} = useSelector(selectors.selectAuthenticateForm);
const dispatch = useDispatch();
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm(() => ({
emailOrUsername: '',
password: '',
...defaultData,
}));
const message = useMemo(() => createMessage(error), [error]);
const [focusPasswordFieldState, focusPasswordField] = useToggle();
const [emailOrUsernameFieldRef, handleEmailOrUsernameFieldRef] = useNestedRef('inputRef');
const [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
emailOrUsername: data.emailOrUsername.trim(),
};
if (!isEmail(cleanData.emailOrUsername) && !isUsername(cleanData.emailOrUsername)) {
emailOrUsernameFieldRef.current.select();
return;
}
if (!cleanData.password) {
passwordFieldRef.current.focus();
return;
}
dispatch(entryActions.authenticate(cleanData));
}, [dispatch, data, emailOrUsernameFieldRef, passwordFieldRef]);
const handleAuthenticateWithOidcClick = useCallback(() => {
dispatch(entryActions.authenticateWithOidc());
}, [dispatch]);
const handleMessageDismiss = useCallback(() => {
dispatch(entryActions.clearAuthenticateError());
}, [dispatch]);
const withOidc = !!config.oidc;
const isOidcEnforced = withOidc && config.oidc.isEnforced;
useEffect(() => {
if (!isOidcEnforced) {
emailOrUsernameFieldRef.current.focus();
}
}, [emailOrUsernameFieldRef, isOidcEnforced]);
useDidUpdate(() => {
if (wasSubmitting && !isSubmitting && error) {
switch (error.message) {
case 'Invalid credentials':
case 'Invalid email or username':
emailOrUsernameFieldRef.current.select();
break;
case 'Invalid password':
setData((prevData) => ({
...prevData,
password: '',
}));
focusPasswordField();
break;
default:
}
}
}, [isSubmitting, wasSubmitting, error]);
useDidUpdate(() => {
passwordFieldRef.current.focus();
}, [focusPasswordFieldState]);
return (
<div className={classNames(styles.wrapper, styles.fullHeight)}>
<Grid verticalAlign="middle" className={classNames(styles.grid, styles.fullHeight)}>
<Grid.Column computer={6} tablet={16} mobile={16}>
<div className={styles.loginWrapper}>
<Header as="h1" textAlign="center" content="PLANKA" className={styles.formTitle} />
<Header
as="h2"
textAlign="center"
content={t('common.logIn', {
context: 'title',
})}
className={styles.formSubtitle}
/>
<div className={styles.formWrapper}>
{message && (
<Message
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={handleMessageDismiss}
/>
)}
{!isOidcEnforced && (
<>
<Form size="large" onSubmit={handleSubmit}>
<div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.emailOrUsername')}</div>
<Input
fluid
ref={handleEmailOrUsernameFieldRef}
name="emailOrUsername"
value={data.emailOrUsername}
maxLength={256}
readOnly={isSubmitting}
className={styles.input}
onChange={handleFieldChange}
/>
</div>
<div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.password')}</div>
<Input.Password
fluid
ref={handlePasswordFieldRef}
name="password"
value={data.password}
maxLength={256}
readOnly={isSubmitting}
className={styles.input}
onChange={handleFieldChange}
/>
</div>
<Form.Button
fluid
primary
icon="right arrow"
labelPosition="right"
content={t('action.logIn')}
loading={isSubmitting}
disabled={isSubmitting || isSubmittingWithOidc}
/>
</Form>
{withOidc && (
<Divider horizontal content={t('common.or')} className={styles.divider} />
)}
</>
)}
{withOidc && (
<Button
fluid
primary={isOidcEnforced}
icon={isOidcEnforced ? 'right arrow' : undefined}
labelPosition={isOidcEnforced ? 'right' : undefined}
content={t('action.logInWithSso')}
loading={isSubmittingWithOidc}
disabled={isSubmitting || isSubmittingWithOidc}
onClick={handleAuthenticateWithOidcClick}
/>
)}
</div>
<p className={styles.formFooter}>{t('common.poweredByPlanka')}</p>
</div>
</Grid.Column>
<Grid.Column
computer={10}
only="computer"
className={classNames(styles.cover, styles.fullHeight)}
>
<div className={styles.coverOverlay} />
</Grid.Column>
</Grid>
</div>
);
});
export default Content;

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
*/
:global(#app) {
.cover {
background: url("../../../assets/images/cover.jpg") left / cover;
}
.coverOverlay {
background: rgba(33, 33, 33, 0.5);
bottom: 0;
height: 100%;
left: 0;
position: absolute;
right: 0;
top: 0;
width: 100%;
}
.divider {
font-weight: normal;
}
.formFooter {
color: #6b808c;
font-weight: lighter;
text-align: center;
}
.formTitle {
font-size: 36px;
font-weight: bold;
margin-bottom: 36px;
}
.formSubtitle {
font-size: 28px;
font-weight: normal;
margin-bottom: 32px;
}
.formWrapper {
margin-bottom: 36px;
}
.fullHeight {
height: 100%;
}
.grid {
margin: 0;
}
.inputLabel {
font-size: 16px;
line-height: 20px;
margin-bottom: 4px;
}
.inputWrapper {
margin-bottom: 16px;
}
.loginWrapper {
margin: 0 auto;
max-width: 440px;
padding: 0 20px;
}
.wrapper {
background: #fff;
}
}

View file

@ -0,0 +1,19 @@
/*!
* 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 { Loader } from 'semantic-ui-react';
import selectors from '../../../selectors';
import Content from './Content';
const Login = React.memo(() => {
const isInitializing = useSelector(selectors.selectIsInitializing);
return isInitializing ? <Loader active size="massive" /> : <Content />;
});
export default Login;

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

View file

@ -0,0 +1,40 @@
/*!
* 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 transform from '@diplodoc/transform';
import { defaultOptions as defaultSanitizeOptions } from '@diplodoc/transform/lib/sanitize';
import { colorClassName } from '@gravity-ui/markdown-editor';
import plugins from '../../configs/markdown-plugins';
const Markdown = React.memo(({ children }) => {
const html = useMemo(() => {
try {
return transform(children, {
plugins,
breaks: true,
linkify: true,
sanitizeOptions: {
...defaultSanitizeOptions,
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
},
defaultClassName: colorClassName,
}).result.html;
} catch (error) {
return error.toString();
}
}, [children]);
// eslint-disable-next-line react/no-danger
return <div dangerouslySetInnerHTML={{ __html: html }} className="yfm" />;
});
Markdown.propTypes = {
children: PropTypes.string.isRequired,
};
export default Markdown;

View file

@ -0,0 +1,180 @@
/*!
* 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 PropTypes from 'prop-types';
import classNames from 'classnames';
import {
useMarkdownEditor,
wysiwygToolbarConfigs,
MarkdownEditorView,
} from '@gravity-ui/markdown-editor';
/* eslint-disable import/no-unresolved */
import { full as toolbarsPreset } from '@gravity-ui/markdown-editor/_/modules/toolbars/presets';
import { ActionName } from '@gravity-ui/markdown-editor/_/bundle/config/action-names';
/* eslint-enable import/no-unresolved */
import { EditorModes } from '../../../constants/Enums';
import styles from './MarkdownEditor.module.scss';
const removedActionNamesSet = new Set([
ActionName.checkbox,
ActionName.file,
ActionName.filePopup,
ActionName.tabs,
]);
removedActionNamesSet.forEach((actionName) => {
delete toolbarsPreset.items[actionName];
Object.entries(toolbarsPreset.orders).forEach(([orderName, order]) => {
order.forEach((actions, actionsIndex) => {
toolbarsPreset.orders[orderName][actionsIndex] = actions.filter(
(action) => action.id || action !== actionName,
);
});
});
});
const commandMenuActions = wysiwygToolbarConfigs.wCommandMenuConfig.filter(
(action) => !removedActionNamesSet.has(action.id),
);
export const fileToBase64Data = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
const fileUploadHandler = async (file) => {
const base64Data = await fileToBase64Data(file);
return { url: base64Data };
};
const MarkdownEditor = React.forwardRef(
(
{ defaultValue, defaultMode, isError, onChange, onSubmit, onCancel, onModeChange, ...props },
ref,
) => {
const wrapperRef = useRef(null);
const handleWrapperRef = useCallback(
(element) => {
wrapperRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element; // eslint-disable-line no-param-reassign
}
},
[ref],
);
const editor = useMarkdownEditor({
md: {
breaks: true,
linkify: true,
},
handlers: {
uploadFile: fileUploadHandler,
},
wysiwygConfig: {
extensionOptions: {
commandMenu: {
actions: commandMenuActions,
},
},
},
initial: {
markup: defaultValue,
mode: defaultMode,
},
});
useEffect(() => {
const handleChange = () => {
onChange(editor.getValue());
};
const handleSubmit = () => {
onSubmit();
};
const handleCancel = () => {
onCancel();
};
const handleModeChange = ({ mode: nextMode }) => {
if (onModeChange) {
onModeChange(nextMode);
}
};
editor.on('change', handleChange);
editor.on('submit', handleSubmit);
editor.on('cancel', handleCancel);
editor.on('change-editor-mode', handleModeChange);
return () => {
editor.off('change', handleChange);
editor.off('submit', handleSubmit);
editor.off('cancel', handleCancel);
editor.off('change-editor-mode', handleModeChange);
};
}, [onChange, onSubmit, onCancel, onModeChange, editor]);
useEffect(() => {
const { current: wrapperElement } = wrapperRef;
const handlePaste = (event) => {
event.stopPropagation();
};
wrapperElement.addEventListener('paste', handlePaste);
return () => {
wrapperElement.removeEventListener('paste', handlePaste);
};
}, []);
return (
<div
{...props} // eslint-disable-line react/jsx-props-no-spreading
ref={handleWrapperRef}
className={classNames(styles.wrapper, isError && styles.wrapperError)}
>
<MarkdownEditorView
autofocus
stickyToolbar
editor={editor}
toolbarsPreset={toolbarsPreset}
className={styles.editor}
/>
</div>
);
},
);
MarkdownEditor.propTypes = {
defaultValue: PropTypes.string.isRequired,
defaultMode: PropTypes.oneOf(Object.values(EditorModes)),
isError: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onModeChange: PropTypes.func,
};
MarkdownEditor.defaultProps = {
defaultMode: EditorModes.WYSIWYG,
isError: false,
onModeChange: undefined,
};
export default React.memo(MarkdownEditor);

View file

@ -0,0 +1,40 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.editor {
min-height: 200px;
:global {
.g-md-markup-editor__editor,
.g-md-wysiwyg-editor__editor {
background: #fff;
}
.g-md-wysiwyg-editor__toolbar {
background: #f5f6f7;
z-index: 2000;
}
.yfm-editor {
height: 100%;
}
}
}
.wrapper {
--g-md-editor-padding: 6px 12px;
--g-md-toolbar-padding: 6px 12px;
--g-md-toolbar-sticky-offset: -21px;
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
}
.wrapperError {
border: 1px solid #cf513d;
}
}

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

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 { useTranslation } from 'react-i18next';
const NotFound = React.memo(() => {
const [t] = useTranslation();
return (
<h1>
{t('common.pageNotFound', {
context: 'title',
})}
</h1>
);
});
export default NotFound;

View file

@ -0,0 +1,56 @@
/*!
* 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 PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom';
import { ThemeProvider, ToasterProvider } from '@gravity-ui/uikit';
// eslint-disable-next-line import/no-unresolved
import { toaster } from '@gravity-ui/uikit/toaster-singleton';
import { ReduxRouter } from '../../lib/redux-router';
import Paths from '../../constants/Paths';
import Login from './Login';
import Core from './Core';
import NotFound from './NotFound';
import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css';
import '@gravity-ui/uikit/styles/styles.css';
import '../../lib/custom-ui/styles.css';
import '../../styles.module.scss';
function Root({ store, history }) {
return (
<Provider store={store}>
<ReduxRouter history={history}>
<ThemeProvider theme="light">
<ToasterProvider toaster={toaster}>
<Routes>
<Route path={Paths.LOGIN} element={<Login />} />
<Route path={Paths.OIDC_CALLBACK} element={<Login />} />
<Route path={Paths.ROOT} element={<Core />} />
<Route path={Paths.PROJECTS} element={<Core />} />
<Route path={Paths.BOARDS} element={<Core />} />
<Route path={Paths.CARDS} element={<Core />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ToasterProvider>
</ThemeProvider>
</ReduxRouter>
</Provider>
);
}
Root.propTypes = {
/* eslint-disable react/forbid-prop-types */
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
};
export default Root;

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
*/
import React, { useRef } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useTranslation, Trans } from 'react-i18next';
import { Icon, Loader } from 'semantic-ui-react';
import { useTransitioning } from '../../../lib/hooks';
import selectors from '../../../selectors';
import { BoardViews } from '../../../constants/Enums';
import Home from '../Home';
import Board from '../../boards/Board';
import styles from './Static.module.scss';
const Static = React.memo(() => {
const { cardId, projectId } = useSelector(selectors.selectPath);
const board = useSelector(selectors.selectCurrentBoard);
const isFetching = useSelector(selectors.selectIsContentFetching);
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
const [t] = useTranslation();
const wrapperRef = useRef(null);
const handleTransitionEnd = useTransitioning(wrapperRef, styles.wrapperTransitioning, [
isFavoritesActive,
]);
let wrapperClassNames;
let contentNode;
if (isFetching) {
wrapperClassNames = [styles.wrapperLoader];
contentNode = <Loader active size="huge" />;
} else if (projectId === undefined) {
wrapperClassNames = [isFavoritesActive && styles.wrapperWithFavorites, styles.wrapperVertical];
contentNode = <Home />;
} else if (cardId === null) {
wrapperClassNames = [isFavoritesActive && styles.wrapperWithFavorites, styles.wrapperFlex];
contentNode = (
<div className={styles.message}>
<h1>
{t('common.cardNotFound', {
context: 'title',
})}
</h1>
</div>
);
} else if (board === null) {
wrapperClassNames = [isFavoritesActive && styles.wrapperWithFavorites, styles.wrapperFlex];
contentNode = (
<div className={styles.message}>
<h1>
{t('common.boardNotFound', {
context: 'title',
})}
</h1>
</div>
);
} else if (projectId === null) {
wrapperClassNames = [isFavoritesActive && styles.wrapperWithFavorites, styles.wrapperFlex];
contentNode = (
<div className={styles.message}>
<h1>
{t('common.projectNotFound', {
context: 'title',
})}
</h1>
</div>
);
} else if (board === undefined) {
wrapperClassNames = [
isFavoritesActive ? styles.wrapperProjectWithFavorites : styles.wrapperProject,
styles.wrapperFlex,
];
contentNode = (
<div className={styles.message}>
<Icon inverted name="hand point up outline" size="huge" className={styles.messageIcon} />
<h1 className={styles.messageTitle}>
{t('common.openBoard', {
context: 'title',
})}
</h1>
<div className={styles.messageContent}>
<Trans i18nKey="common.createNewOneOrSelectExistingOne" />
</div>
</div>
);
} else if (board.isFetching) {
wrapperClassNames = [
styles.wrapperLoader,
isFavoritesActive ? styles.wrapperProjectWithFavorites : styles.wrapperProject,
];
contentNode = <Loader active size="big" />;
} else {
wrapperClassNames = [
isFavoritesActive ? styles.wrapperBoardWithFavorites : styles.wrapperBoard,
[BoardViews.GRID, BoardViews.LIST].includes(board.view) && styles.wrapperVertical,
styles.wrapperFlex,
];
contentNode = <Board />;
}
return (
<div
ref={wrapperRef}
className={classNames(styles.wrapper, ...wrapperClassNames)}
onTransitionEnd={handleTransitionEnd}
>
{contentNode}
</div>
);
});
export default Static;

View file

@ -0,0 +1,73 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.message {
align-content: space-between;
align-items: center;
color: #fff;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
}
.messageIcon {
margin-top: -84px;
}
.messageTitle {
font-size: 32px;
margin: 24px 0 8px;
}
.messageContent {
font-size: 18px;
line-height: 1.4;
margin: 4px 0 0;
text-align: center;
}
.wrapper {
height: 100%;
margin-top: 116px;
}
.wrapperBoard {
margin-top: 174px;
}
.wrapperBoardWithFavorites {
margin-top: 264px;
}
.wrapperFlex {
display: flex;
}
.wrapperLoader {
position: relative;
}
.wrapperProject {
margin-top: 98px;
}
.wrapperProjectWithFavorites {
margin-top: 188px;
}
.wrapperTransitioning {
transition: margin-top 0.2s ease;
}
.wrapperVertical {
overflow: hidden;
}
.wrapperWithFavorites {
margin-top: 206px;
}
}

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

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
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './ExpirableTime.module.scss';
const DAY = 1000 * 60 * 60 * 24;
const isExpired = (value) => value <= Date.now() - DAY;
const ExpirableTime = React.memo(({ children, date, verboseDate, tooltip, ...props }) => (
<time
{...props} // eslint-disable-line react/jsx-props-no-spreading
dateTime={date.toISOString()}
title={tooltip ? verboseDate : undefined}
className={classNames(isExpired(date) && styles.expired)}
>
{children}
</time>
));
ExpirableTime.propTypes = {
children: PropTypes.string.isRequired,
date: PropTypes.instanceOf(Date).isRequired,
verboseDate: PropTypes.string,
tooltip: PropTypes.bool.isRequired,
};
ExpirableTime.defaultProps = {
verboseDate: undefined,
};
export default ExpirableTime;

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) {
.expired {
color: #cf513d;
}
}

View file

@ -0,0 +1,46 @@
/*!
* 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 ReactTimeAgo from 'react-time-ago';
import getDateFormat from '../../../utils/get-date-format';
import ExpirableTime from './ExpirableTime';
const TimeAgo = React.memo(({ date, withExpiration }) => {
const [t, i18n] = useTranslation();
const verboseDateFormatter = useCallback(
(value) =>
t(`format:${getDateFormat(value)}`, {
value,
postProcess: 'formatDate',
}),
[t],
);
return (
<ReactTimeAgo
date={date}
timeStyle="round-minute"
locale={i18n.resolvedLanguage}
component={withExpiration ? ExpirableTime : undefined}
formatVerboseDate={verboseDateFormatter}
/>
);
});
TimeAgo.propTypes = {
date: PropTypes.instanceOf(Date).isRequired,
withExpiration: PropTypes.bool,
};
TimeAgo.defaultProps = {
withExpiration: false,
};
export default TimeAgo;

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

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 React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { Button, Icon, Message } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { BoardContexts } from '../../../constants/Enums';
import { BoardContextIcons } from '../../../constants/Icons';
import styles from './EmptyTrashToast.module.scss';
const EmptyTrashToast = React.memo(({ id, listId }) => {
const isCurrentList = useSelector((state) => listId === selectors.selectCurrentListId(state));
const dispatch = useDispatch();
const [t] = useTranslation();
const handleReturnClick = useCallback(() => {
dispatch(entryActions.updateContextInCurrentBoard(BoardContexts.BOARD));
toast.dismiss(id);
}, [id, dispatch]);
return (
<Message visible positive size="tiny">
<Icon name="checkmark" />
{t('common.trashHasBeenSuccessfullyEmptied')}
{isCurrentList && (
<Button
content={t(`action.returnToBoard`)}
icon={BoardContextIcons[BoardContexts.BOARD]}
size="mini"
className={styles.button}
onClick={handleReturnClick}
/>
)}
</Message>
);
});
EmptyTrashToast.propTypes = {
id: PropTypes.string.isRequired,
listId: PropTypes.string.isRequired,
};
export default EmptyTrashToast;

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) {
.button {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-left: 6px;
margin-right: 0;
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,39 @@
/*!
* 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 { Toaster as HotToaster, ToastBar as HotToastBar } from 'react-hot-toast';
import ToastTypes from '../../../constants/ToastTypes';
import EmptyTrashToast from './EmptyTrashToast';
const TOAST_BY_TYPE = {
[ToastTypes.EMPTY_TRASH]: EmptyTrashToast,
};
const Toaster = React.memo(() => (
<HotToaster>
{(toast) => (
<HotToastBar
toast={toast}
style={{
background: 'transparent',
borderRadius: 0,
maxWidth: '90%',
padding: 0,
}}
>
{() => {
const Toast = TOAST_BY_TYPE[toast.message.type];
// eslint-disable-next-line react/jsx-props-no-spreading
return <Toast {...toast.message.params} id={toast.id} />;
}}
</HotToastBar>
)}
</HotToaster>
));
export default Toaster;

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