mirror of
https://github.com/plankanban/planka.git
synced 2025-07-27 17:19:43 +02:00
parent
ad7fb51cfa
commit
2ee1166747
1557 changed files with 76832 additions and 47042 deletions
|
@ -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;
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
57
client/src/components/common/AdministrationModal/UsersPane/Item.jsx
Executable file
57
client/src/components/common/AdministrationModal/UsersPane/Item.jsx
Executable 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
126
client/src/components/common/AdministrationModal/UsersPane/UsersPane.jsx
Executable file
126
client/src/components/common/AdministrationModal/UsersPane/UsersPane.jsx
Executable 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
8
client/src/components/common/AdministrationModal/index.js
Executable file
8
client/src/components/common/AdministrationModal/index.js
Executable 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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
8
client/src/components/common/ConfirmationStep/index.js
Normal file
8
client/src/components/common/ConfirmationStep/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import ConfirmationStep from './ConfirmationStep';
|
||||
|
||||
export default ConfirmationStep;
|
126
client/src/components/common/Core/Core.jsx
Normal file
126
client/src/components/common/Core/Core.jsx
Normal 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;
|
31
client/src/components/common/Core/Message.jsx
Normal file
31
client/src/components/common/Core/Message.jsx
Normal 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;
|
47
client/src/components/common/Core/Message.module.scss
Normal file
47
client/src/components/common/Core/Message.module.scss
Normal 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;
|
||||
}
|
||||
}
|
8
client/src/components/common/Core/index.js
Normal file
8
client/src/components/common/Core/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Core from './Core';
|
||||
|
||||
export default Core;
|
136
client/src/components/common/EditMarkdown/EditMarkdown.jsx
Executable file
136
client/src/components/common/EditMarkdown/EditMarkdown.jsx
Executable 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
8
client/src/components/common/EditMarkdown/index.js
Normal file
8
client/src/components/common/EditMarkdown/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import EditMarkdown from './EditMarkdown';
|
||||
|
||||
export default EditMarkdown;
|
49
client/src/components/common/Favorites/Favorites.jsx
Normal file
49
client/src/components/common/Favorites/Favorites.jsx
Normal 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;
|
55
client/src/components/common/Favorites/Favorites.module.scss
Normal file
55
client/src/components/common/Favorites/Favorites.module.scss
Normal 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;
|
||||
}
|
||||
}
|
8
client/src/components/common/Favorites/index.js
Normal file
8
client/src/components/common/Favorites/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Favorites from './Favorites';
|
||||
|
||||
export default Favorites;
|
33
client/src/components/common/Fixed/Fixed.jsx
Normal file
33
client/src/components/common/Fixed/Fixed.jsx
Normal 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;
|
12
client/src/components/common/Fixed/Fixed.module.scss
Normal file
12
client/src/components/common/Fixed/Fixed.module.scss
Normal 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;
|
||||
}
|
||||
}
|
8
client/src/components/common/Fixed/index.js
Normal file
8
client/src/components/common/Fixed/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Fixed from './Fixed';
|
||||
|
||||
export default Fixed;
|
167
client/src/components/common/Header/Header.jsx
Executable file
167
client/src/components/common/Header/Header.jsx
Executable 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;
|
110
client/src/components/common/Header/Header.module.scss
Normal file
110
client/src/components/common/Header/Header.module.scss
Normal 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;
|
||||
}
|
||||
}
|
8
client/src/components/common/Header/index.js
Executable file
8
client/src/components/common/Header/index.js
Executable 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;
|
25
client/src/components/common/Home/GridProjectsView.jsx
Normal file
25
client/src/components/common/Home/GridProjectsView.jsx
Normal 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;
|
75
client/src/components/common/Home/GroupedProjectsView.jsx
Normal file
75
client/src/components/common/Home/GroupedProjectsView.jsx
Normal 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;
|
39
client/src/components/common/Home/Home.jsx
Executable file
39
client/src/components/common/Home/Home.jsx
Executable 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;
|
18
client/src/components/common/Home/Home.module.scss
Normal file
18
client/src/components/common/Home/Home.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
87
client/src/components/common/Home/Projects.jsx
Normal file
87
client/src/components/common/Home/Projects.jsx
Normal 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;
|
115
client/src/components/common/Home/Projects.module.scss
Normal file
115
client/src/components/common/Home/Projects.module.scss
Normal 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;
|
||||
}
|
||||
}
|
8
client/src/components/common/Home/index.js
Normal file
8
client/src/components/common/Home/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Home from './Home';
|
||||
|
||||
export default Home;
|
99
client/src/components/common/HomeActions/Filters.jsx
Normal file
99
client/src/components/common/HomeActions/Filters.jsx
Normal 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;
|
32
client/src/components/common/HomeActions/Filters.module.scss
Normal file
32
client/src/components/common/HomeActions/Filters.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
client/src/components/common/HomeActions/HomeActions.jsx
Normal file
29
client/src/components/common/HomeActions/HomeActions.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
8
client/src/components/common/HomeActions/index.js
Normal file
8
client/src/components/common/HomeActions/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import HomeActions from './HomeActions';
|
||||
|
||||
export default HomeActions;
|
75
client/src/components/common/Linkify.jsx
Normal file
75
client/src/components/common/Linkify.jsx
Normal 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;
|
265
client/src/components/common/Login/Content.jsx
Normal file
265
client/src/components/common/Login/Content.jsx
Normal 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;
|
75
client/src/components/common/Login/Content.module.scss
Normal file
75
client/src/components/common/Login/Content.module.scss
Normal 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;
|
||||
}
|
||||
}
|
19
client/src/components/common/Login/Login.jsx
Executable file
19
client/src/components/common/Login/Login.jsx
Executable 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;
|
8
client/src/components/common/Login/index.js
Normal file
8
client/src/components/common/Login/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Login from './Login';
|
||||
|
||||
export default Login;
|
40
client/src/components/common/Markdown.jsx
Normal file
40
client/src/components/common/Markdown.jsx
Normal 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;
|
180
client/src/components/common/MarkdownEditor/MarkdownEditor.jsx
Normal file
180
client/src/components/common/MarkdownEditor/MarkdownEditor.jsx
Normal 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);
|
|
@ -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;
|
||||
}
|
||||
}
|
8
client/src/components/common/MarkdownEditor/index.js
Normal file
8
client/src/components/common/MarkdownEditor/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import MarkdownEditor from './MarkdownEditor';
|
||||
|
||||
export default MarkdownEditor;
|
21
client/src/components/common/NotFound.jsx
Executable file
21
client/src/components/common/NotFound.jsx
Executable 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;
|
56
client/src/components/common/Root.jsx
Executable file
56
client/src/components/common/Root.jsx
Executable 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;
|
126
client/src/components/common/Static/Static.jsx
Normal file
126
client/src/components/common/Static/Static.jsx
Normal 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;
|
73
client/src/components/common/Static/Static.module.scss
Normal file
73
client/src/components/common/Static/Static.module.scss
Normal 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;
|
||||
}
|
||||
}
|
8
client/src/components/common/Static/index.js
Normal file
8
client/src/components/common/Static/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Static from './Static';
|
||||
|
||||
export default Static;
|
38
client/src/components/common/TimeAgo/ExpirableTime.jsx
Normal file
38
client/src/components/common/TimeAgo/ExpirableTime.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
46
client/src/components/common/TimeAgo/TimeAgo.jsx
Normal file
46
client/src/components/common/TimeAgo/TimeAgo.jsx
Normal 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;
|
8
client/src/components/common/TimeAgo/index.js
Normal file
8
client/src/components/common/TimeAgo/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import TimeAgo from './TimeAgo';
|
||||
|
||||
export default TimeAgo;
|
53
client/src/components/common/Toaster/EmptyTrashToast.jsx
Normal file
53
client/src/components/common/Toaster/EmptyTrashToast.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
39
client/src/components/common/Toaster/Toaster.jsx
Normal file
39
client/src/components/common/Toaster/Toaster.jsx
Normal 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;
|
8
client/src/components/common/Toaster/index.js
Normal file
8
client/src/components/common/Toaster/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Toaster from './Toaster';
|
||||
|
||||
export default Toaster;
|
Loading…
Add table
Add a link
Reference in a new issue