feat: Version 2

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

View File

@@ -0,0 +1,22 @@
/*!
* 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 { Image, Tab } from 'semantic-ui-react';
import version from '../../../version';
import logo from '../../../assets/images/logo.png';
import styles from './AboutPane.module.scss';
const AboutPane = React.memo(() => (
<Tab.Pane attached={false} className={styles.wrapper}>
<Image centered src={logo} size="large" />
<div className={styles.version}>{version}</div>
</Tab.Pane>
));
export default AboutPane;

View File

@@ -0,0 +1,16 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.version {
font-weight: bold;
text-align: center;
}
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -0,0 +1,128 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Dropdown, Header, Tab } from 'semantic-ui-react';
import { usePopupInClosableContext } from '../../../../hooks';
import locales from '../../../../locales';
import EditAvatarStep from './EditAvatarStep';
import EditUserInformation from '../../EditUserInformation';
import EditUserUsernameStep from '../../EditUserUsernameStep';
import EditUserEmailStep from '../../EditUserEmailStep';
import EditUserPasswordStep from '../../EditUserPasswordStep';
import UserAvatar from '../../UserAvatar';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import styles from './AccountPane.module.scss';
const AccountPane = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleLanguageChange = useCallback(
(_, { value }) => {
// FIXME: hack
dispatch(entryActions.updateCurrentUserLanguage(value === 'auto' ? null : value));
},
[dispatch],
);
const EditAvatarPopup = usePopupInClosableContext(EditAvatarStep);
const EditUserUsernamePopup = usePopupInClosableContext(EditUserUsernameStep);
const EditUserEmailPopup = usePopupInClosableContext(EditUserEmailStep);
const EditUserPasswordPopup = usePopupInClosableContext(EditUserPasswordStep);
const isUsernameEditable = !user.lockedFieldNames.includes('username');
const isEmailEditable = !user.lockedFieldNames.includes('email');
const isPasswordEditable = !user.lockedFieldNames.includes('password');
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<EditAvatarPopup>
<UserAvatar id={user.id} size="massive" isDisabled={user.isAvatarUpdating} />
</EditAvatarPopup>
<br />
<br />
<EditUserInformation id={user.id} />
<Divider horizontal section>
<Header as="h4">
{t('common.language', {
context: 'title',
})}
</Header>
</Divider>
<Dropdown
fluid
selection
options={[
{
value: 'auto',
text: t('common.detectAutomatically'),
},
...locales.map((locale) => ({
value: locale.language,
flag: locale.country,
text: locale.name,
})),
]}
value={user.language || 'auto'}
onChange={handleLanguageChange}
/>
{(isUsernameEditable || isEmailEditable || isPasswordEditable) && (
<>
<Divider horizontal section>
<Header as="h4">
{t('common.authentication', {
context: 'title',
})}
</Header>
</Divider>
{isUsernameEditable && (
<div className={styles.action}>
<EditUserUsernamePopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
})}
</Button>
</EditUserUsernamePopup>
</div>
)}
{isEmailEditable && (
<div className={styles.action}>
<EditUserEmailPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
})}
</Button>
</EditUserEmailPopup>
</div>
)}
{isPasswordEditable && (
<div className={styles.action}>
<EditUserPasswordPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',
})}
</Button>
</EditUserPasswordPopup>
</div>
)}
</>
)}
</Tab.Pane>
);
});
export default AccountPane;

View File

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

View File

@@ -0,0 +1,82 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import styles from './EditAvatarStep.module.scss';
const EditAvatarStep = React.memo(({ onClose }) => {
const defaultValue = useSelector((state) => selectors.selectCurrentUser(state).avatar);
const dispatch = useDispatch();
const [t] = useTranslation();
const fieldRef = useRef(null);
const handleFileSelect = useCallback(
(file) => {
dispatch(
entryActions.updateCurrentUserAvatar({
file,
}),
);
onClose();
},
[onClose, dispatch],
);
const handleDeleteClick = useCallback(() => {
dispatch(
entryActions.updateCurrentUser({
avatar: null,
}),
);
onClose();
}, [onClose, dispatch]);
useEffect(() => {
fieldRef.current.focus();
}, []);
return (
<>
<Popup.Header>
{t('common.editAvatar', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.action}>
<FilePicker accept="image/*" onSelect={handleFileSelect}>
<Button
ref={fieldRef}
content={t('action.uploadNewAvatar')}
className={styles.actionButton}
/>
</FilePicker>
</div>
{defaultValue && (
<Button negative content={t('action.deleteAvatar')} onClick={handleDeleteClick} />
)}
</Popup.Content>
</>
);
});
EditAvatarStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default EditAvatarStep;

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
/*!
* 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 { Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import NotificationServices from '../../notification-services/NotificationServices';
import styles from './NotificationsPane.module.scss';
const NotificationsPane = React.memo(() => {
const notificationServiceIds = useSelector(selectors.selectNotificationServiceIdsForCurrentUser);
const dispatch = useDispatch();
const handleCreate = useCallback(
(data) => {
dispatch(entryActions.createNotificationServiceInCurrentUser(data));
},
[dispatch],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<NotificationServices ids={notificationServiceIds} onCreate={handleCreate} />
</Tab.Pane>
);
});
export default NotificationsPane;

View File

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

View File

@@ -0,0 +1,63 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Radio, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import styles from './PreferencesPane.module.scss';
const PreferencesPane = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleChange = useCallback(
(_, { name: fieldName, checked }) => {
dispatch(
entryActions.updateCurrentUser({
[fieldName]: checked,
}),
);
},
[dispatch],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Radio
toggle
name="subscribeToOwnCards"
checked={user.subscribeToOwnCards}
label={t('common.subscribeToMyOwnCardsByDefault')}
className={styles.radio}
onChange={handleChange}
/>
<Radio
toggle
name="subscribeToCardWhenCommenting"
checked={user.subscribeToCardWhenCommenting}
label={t('common.subscribeToCardWhenCommenting')}
className={styles.radio}
onChange={handleChange}
/>
<Radio
toggle
name="turnOffRecentCardHighlighting"
checked={user.turnOffRecentCardHighlighting}
label={t('common.turnOffRecentCardHighlighting')}
className={styles.radio}
onChange={handleChange}
/>
</Tab.Pane>
);
});
export default PreferencesPane;

View File

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

View File

@@ -0,0 +1,70 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Tab } from 'semantic-ui-react';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import AccountPane from './AccountPane';
import PreferencesPane from './PreferencesPane';
import NotificationsPane from './NotificationsPane';
import AboutPane from './AboutPane';
const UserSettingsModal = React.memo(() => {
const dispatch = useDispatch();
const [t] = useTranslation();
const handleClose = useCallback(() => {
dispatch(entryActions.closeModal());
}, [dispatch]);
const [ClosableModal] = useClosableModal();
const panes = [
{
menuItem: t('common.account', {
context: 'title',
}),
render: () => <AccountPane />,
},
{
menuItem: t('common.preferences', {
context: 'title',
}),
render: () => <PreferencesPane />,
},
{
menuItem: t('common.notifications', {
context: 'title',
}),
render: () => <NotificationsPane />,
},
{
menuItem: t('common.aboutPlanka', {
context: 'title',
}),
render: () => <AboutPane />,
},
];
return (
<ClosableModal open closeIcon size="small" centered={false} onClose={handleClose}>
<ClosableModal.Content>
<Tab
menu={{
secondary: true,
pointing: true,
}}
panes={panes}
/>
</ClosableModal.Content>
</ClosableModal>
);
});
export default UserSettingsModal;

View File

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