mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
Support multiple editor session and improve persistence. (#260)
* Move the cursor to the end upon focusing text area * Allow multiple editor session and improve autosave * Fix data fetching * Fix mobile flash style
This commit is contained in:
parent
d84e4c6e57
commit
edfaa3f953
36 changed files with 700 additions and 1669 deletions
|
|
@ -5,9 +5,9 @@
|
|||
"repository": "https://github.com/dnote/dnote",
|
||||
"main": "index",
|
||||
"scripts": {
|
||||
"test": "./node_modules/karma/bin/karma start ./karma.conf.js --single-run",
|
||||
"test:watch": "./node_modules/karma/bin/karma start ./karma.conf.js --watch",
|
||||
"lint": "./node_modules/.bin/eslint ./src"
|
||||
"test": "karma start ./karma.conf.js --single-run",
|
||||
"test:watch": "karma start ./karma.conf.js --watch",
|
||||
"lint": "eslint ./src/**/*.ts ./src/**/*.tsx"
|
||||
},
|
||||
"author": "Monomax Software Pty Ltd",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { Provider } from 'react-redux';
|
|||
import { debounce } from 'jslib/helpers/perf';
|
||||
import App from './components/App';
|
||||
import configureStore from './store';
|
||||
import { markPersisted } from './store/editor';
|
||||
import { loadState, saveState } from './libs/localStorage';
|
||||
import './libs/restoreScroll';
|
||||
|
||||
|
|
@ -40,7 +41,11 @@ store.subscribe(
|
|||
saveState({
|
||||
editor: state.editor
|
||||
});
|
||||
}, 1000)
|
||||
|
||||
if (!state.editor.persisted) {
|
||||
store.dispatch(markPersisted());
|
||||
}
|
||||
}, 50)
|
||||
);
|
||||
|
||||
function renderApp() {
|
||||
|
|
|
|||
|
|
@ -56,15 +56,21 @@ interface Props extends RouteComponentProps<any> {}
|
|||
function useFetchData() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getCurrentUser()).then(u => {
|
||||
if (!u) {
|
||||
return null;
|
||||
}
|
||||
const { user } = useSelector(state => {
|
||||
return {
|
||||
user: state.auth.user
|
||||
};
|
||||
});
|
||||
|
||||
return dispatch(getBooks());
|
||||
});
|
||||
useEffect(() => {
|
||||
dispatch(getCurrentUser());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user.isFetched) {
|
||||
dispatch(getBooks());
|
||||
}
|
||||
}, [dispatch, user.isFetched]);
|
||||
}
|
||||
|
||||
function hasLocationChanged(loc1: Location<any>, loc2: Location<any>) {
|
||||
|
|
|
|||
|
|
@ -66,10 +66,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.trigger-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.book-label {
|
||||
margin-left: rem(8px);
|
||||
font-weight: 600;
|
||||
|
|
|
|||
|
|
@ -26,11 +26,12 @@ import BookIcon from '../../../Icons/Book';
|
|||
import CaretIcon from '../../../Icons/Caret';
|
||||
import SearchInput from '../../SearchInput';
|
||||
import { useDispatch, useSelector } from '../../../../store';
|
||||
import { updateBook } from '../../../../store/editor';
|
||||
import { updateBook, EditorSession } from '../../../../store/editor';
|
||||
import OptionItem from './OptionItem';
|
||||
import styles from './index.scss';
|
||||
|
||||
interface Props {
|
||||
editor: EditorSession;
|
||||
wrapperClassName?: string;
|
||||
triggerClassName?: string;
|
||||
isReady: boolean;
|
||||
|
|
@ -42,6 +43,7 @@ interface Props {
|
|||
}
|
||||
|
||||
const BookSelector: React.SFC<Props> = ({
|
||||
editor,
|
||||
wrapperClassName,
|
||||
triggerClassName,
|
||||
isReady,
|
||||
|
|
@ -50,10 +52,9 @@ const BookSelector: React.SFC<Props> = ({
|
|||
setIsOpen,
|
||||
triggerRef
|
||||
}) => {
|
||||
const { books, editor } = useSelector(state => {
|
||||
const { books } = useSelector(state => {
|
||||
return {
|
||||
books: state.books,
|
||||
editor: state.editor
|
||||
books: state.books
|
||||
};
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
|
|
@ -76,7 +77,13 @@ const BookSelector: React.SFC<Props> = ({
|
|||
}
|
||||
|
||||
function handleSelect(option) {
|
||||
dispatch(updateBook({ label: option.label, uuid: option.value }));
|
||||
dispatch(
|
||||
updateBook({
|
||||
sessionKey: editor.sessionKey,
|
||||
label: option.label,
|
||||
uuid: option.value
|
||||
})
|
||||
);
|
||||
onAfterChange();
|
||||
}
|
||||
|
||||
|
|
@ -105,10 +112,7 @@ const BookSelector: React.SFC<Props> = ({
|
|||
className={classnames(
|
||||
styles.trigger,
|
||||
triggerClassName,
|
||||
triggerProps.triggerClassName,
|
||||
{
|
||||
[styles['trigger-hidden']]: !isReady
|
||||
}
|
||||
triggerProps.triggerClassName
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
|
|
@ -116,17 +120,18 @@ const BookSelector: React.SFC<Props> = ({
|
|||
aria-haspopup="menu"
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-controls="book-filter"
|
||||
disabled={books.isFetching}
|
||||
disabled={!isReady}
|
||||
>
|
||||
<span className={styles['book-selector-trigger']}>
|
||||
<span className={styles['book-selector-trigger-left']}>
|
||||
<BookIcon width={12} height={12} />
|
||||
<span
|
||||
id="T-book-selector-current-label"
|
||||
className={classnames(styles['book-label'], {
|
||||
[styles['book-label-visible']]: Boolean(currentLabel)
|
||||
})}
|
||||
>
|
||||
{currentLabel || 'Choose a book'}
|
||||
{isReady ? currentLabel || 'Choose a book' : 'Loading...'}
|
||||
</span>
|
||||
</span>
|
||||
<CaretIcon
|
||||
|
|
|
|||
|
|
@ -17,19 +17,18 @@
|
|||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { KEYCODE_ENTER } from 'jslib/helpers/keyboard';
|
||||
import { flushContent } from '../../../store/editor';
|
||||
import { AppState } from '../../../store';
|
||||
import { AppState, useDispatch } from '../../../store';
|
||||
import styles from './Textarea.scss';
|
||||
import editorStyles from './Editor.scss';
|
||||
|
||||
interface Props {
|
||||
sessionKey: string;
|
||||
content: string;
|
||||
onChange: (string) => void;
|
||||
doFlushContent: (string) => void;
|
||||
onSubmit: () => void;
|
||||
textareaRef: React.MutableRefObject<any>;
|
||||
inputTimerRef: React.MutableRefObject<any>;
|
||||
|
|
@ -37,15 +36,16 @@ interface Props {
|
|||
}
|
||||
|
||||
const Textarea: React.SFC<Props> = ({
|
||||
sessionKey,
|
||||
content,
|
||||
onChange,
|
||||
doFlushContent,
|
||||
onSubmit,
|
||||
textareaRef,
|
||||
inputTimerRef,
|
||||
disabled
|
||||
}) => {
|
||||
const [contentFocused, setContentFocused] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div className={classnames(styles.wrapper, editorStyles.content)}>
|
||||
|
|
@ -65,7 +65,7 @@ const Textarea: React.SFC<Props> = ({
|
|||
// eslint-disable-next-line no-param-reassign
|
||||
inputTimerRef.current = null;
|
||||
|
||||
doFlushContent(value);
|
||||
dispatch(flushContent(sessionKey, value));
|
||||
}, 1000);
|
||||
}}
|
||||
onFocus={() => {
|
||||
|
|
@ -93,17 +93,4 @@ const Textarea: React.SFC<Props> = ({
|
|||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: AppState) {
|
||||
return {
|
||||
editor: state.editor
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
doFlushContent: flushContent
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Textarea);
|
||||
export default Textarea;
|
||||
|
|
|
|||
|
|
@ -25,13 +25,30 @@ import { focusTextarea } from 'web/libs/dom';
|
|||
import { getHomePath } from 'web/libs/paths';
|
||||
import BooksSelector from './BookSelector';
|
||||
import { useDispatch, useSelector } from '../../../store';
|
||||
import { flushContent, markDirty } from '../../../store/editor';
|
||||
import {
|
||||
flushContent,
|
||||
resetEditor,
|
||||
EditorSession
|
||||
} from '../../../store/editor';
|
||||
import Textarea from './Textarea';
|
||||
import Preview from './Preview';
|
||||
import Button from '../Button';
|
||||
import styles from './Editor.scss';
|
||||
|
||||
function getContentCacheKey(editorSessionKey: string) {
|
||||
return `editor.${editorSessionKey}.content`;
|
||||
}
|
||||
|
||||
function useEditorContent(
|
||||
editor: EditorSession,
|
||||
cacheKey: string
|
||||
): [string, React.Dispatch<any>] {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
return useState(cached || editor.content);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
editor: EditorSession;
|
||||
onSubmit: (param: { draftContent: string; draftBookUUID: string }) => void;
|
||||
isBusy: boolean;
|
||||
cancelPath?: Location<any>;
|
||||
|
|
@ -47,6 +64,7 @@ enum Mode {
|
|||
}
|
||||
|
||||
const Editor: React.SFC<Props> = ({
|
||||
editor,
|
||||
onSubmit,
|
||||
isBusy,
|
||||
disabled,
|
||||
|
|
@ -55,16 +73,16 @@ const Editor: React.SFC<Props> = ({
|
|||
bookSelectorTriggerRef,
|
||||
cancelPath = getHomePath()
|
||||
}) => {
|
||||
const { editor, books } = useSelector(state => {
|
||||
const { books } = useSelector(state => {
|
||||
return {
|
||||
editor: state.editor,
|
||||
books: state.books
|
||||
};
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const [bookSelectorOpen, setBookSelectorOpen] = useState(false);
|
||||
|
||||
const [content, setContent] = useState(editor.content);
|
||||
const contentCacheKey = getContentCacheKey(editor.sessionKey);
|
||||
const [content, setContent] = useEditorContent(editor, contentCacheKey);
|
||||
const [mode, setMode] = useState(Mode.write);
|
||||
const inputTimerRef = useRef(null);
|
||||
|
||||
|
|
@ -78,10 +96,11 @@ const Editor: React.SFC<Props> = ({
|
|||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
inputTimerRef.current = null;
|
||||
dispatch(flushContent(content));
|
||||
dispatch(flushContent(editor.sessionKey, content));
|
||||
}
|
||||
|
||||
onSubmit({ draftContent: content, draftBookUUID: editor.bookUUID });
|
||||
localStorage.removeItem(contentCacheKey);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
|
|
@ -100,13 +119,12 @@ const Editor: React.SFC<Props> = ({
|
|||
<div className={classnames(styles.row, styles['editor-header'])}>
|
||||
<div>
|
||||
<BooksSelector
|
||||
editor={editor}
|
||||
isReady={books.isFetched}
|
||||
isOpen={bookSelectorOpen}
|
||||
setIsOpen={setBookSelectorOpen}
|
||||
triggerRef={bookSelectorTriggerRef}
|
||||
onAfterChange={() => {
|
||||
dispatch(markDirty());
|
||||
|
||||
if (textareaRef.current) {
|
||||
focusTextarea(textareaRef.current);
|
||||
}
|
||||
|
|
@ -148,10 +166,14 @@ const Editor: React.SFC<Props> = ({
|
|||
<div className={styles['content-wrapper']}>
|
||||
{mode === Mode.write ? (
|
||||
<Textarea
|
||||
sessionKey={editor.sessionKey}
|
||||
textareaRef={textareaRef}
|
||||
inputTimerRef={inputTimerRef}
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
onChange={c => {
|
||||
localStorage.setItem(contentCacheKey, c);
|
||||
setContent(c);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -170,7 +192,20 @@ const Editor: React.SFC<Props> = ({
|
|||
{isNew ? 'Save' : 'Update'}
|
||||
</Button>
|
||||
|
||||
<Link to={cancelPath} className="button button-second button-normal">
|
||||
<Link
|
||||
to={cancelPath}
|
||||
onClick={e => {
|
||||
const ok = window.confirm('Are you sure?');
|
||||
if (!ok) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(contentCacheKey);
|
||||
dispatch(resetEditor(editor.sessionKey));
|
||||
}}
|
||||
className="button button-second button-normal"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import Flash from './Flash';
|
||||
import Button from './Button';
|
||||
|
||||
import * as usersService from 'jslib/services/users';
|
||||
|
||||
import styles from './EmailVerifyBanner.module.scss';
|
||||
|
||||
function EmailVerifyBanner({ demo, userData }) {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const user = userData.data;
|
||||
|
||||
if (demo || !user.cloud || user.email_verified) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
setSubmitting(true);
|
||||
setErrorMsg('');
|
||||
|
||||
usersService
|
||||
.sendEmailVerificationEmail(user.api_key)
|
||||
.then(() => {
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
})
|
||||
.catch(err => {
|
||||
setSubmitting(false);
|
||||
setErrorMsg(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessage() {
|
||||
const { email } = user;
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Fragment>
|
||||
Your verification email was sent to
|
||||
<strong className={styles.address}>{email}.</strong> If you do not
|
||||
receive it, please check spam.
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
return (
|
||||
<Fragment>
|
||||
Error sending verification email to{' '}
|
||||
<strong className={styles.address}>{email}</strong>: {errorMsg}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
You need to verify your email
|
||||
<strong className={styles.address}>{email}</strong>
|
||||
to receive weekly digest.
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
let type;
|
||||
|
||||
if (errorMsg) {
|
||||
type = 'danger';
|
||||
} else {
|
||||
type = 'info';
|
||||
}
|
||||
|
||||
return (
|
||||
<Flash
|
||||
id="T-email-verify-banner"
|
||||
wrapperClassName={classnames(styles.banner, {
|
||||
'T-submitted': submitted
|
||||
})}
|
||||
contentClassName={styles.wrapper}
|
||||
type={type}
|
||||
>
|
||||
<div>{renderMessage()}</div>
|
||||
|
||||
{!submitted && (
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<Button type="submit" kind="second" isBusy={submitting}>
|
||||
Send verification email
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Flash>
|
||||
);
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
userData: state.auth.user
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(EmailVerifyBanner);
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../App/responsive';
|
||||
@import '../App/rem';
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
margin-bottom: rem(24px);
|
||||
margin-top: rem(24px);
|
||||
height: auto;
|
||||
|
||||
@include breakpoint(lg) {
|
||||
height: rem(52px);
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
flex-grow: 1;
|
||||
|
||||
@include breakpoint(lg) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.address {
|
||||
font-weight: 600;
|
||||
margin: 0 rem(4px);
|
||||
}
|
||||
|
||||
.form {
|
||||
margin-top: rem(4px);
|
||||
text-align: right;
|
||||
|
||||
@include breakpoint(lg) {
|
||||
margin-top: 0;
|
||||
text-align: initial;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { decorate } from './internal';
|
||||
|
||||
function SafeNavLink({
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
activeClassName,
|
||||
isActive
|
||||
}) {
|
||||
const Decorated = decorate(NavLink, {
|
||||
to,
|
||||
className,
|
||||
onClick,
|
||||
activeClassName,
|
||||
isActive,
|
||||
children
|
||||
});
|
||||
|
||||
return <Decorated />;
|
||||
}
|
||||
|
||||
export default SafeNavLink;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
function handleLinkClick({ event, editorData }) {
|
||||
if (editorData.dirty) {
|
||||
const ok = window.confirm('Your unsaved changes will be lost. Continue?');
|
||||
|
||||
if (!ok) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
editorData: state.editor
|
||||
};
|
||||
}
|
||||
|
||||
// decorate wraps the given link component from react-rotuer-dom to prevent
|
||||
// navigation if the current draft is dirty.
|
||||
export function decorate(LinkComponent, props) {
|
||||
function Decorated({ noteData, editorData }) {
|
||||
return (
|
||||
<LinkComponent
|
||||
{...props}
|
||||
onClick={e => {
|
||||
if (props.onClick) {
|
||||
props.onClick(e);
|
||||
}
|
||||
|
||||
handleLinkClick({ event: e, noteData, editorData });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return connect(mapStateToProps)(Decorated);
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
@import '../App/rem';
|
||||
|
||||
.wrapper {
|
||||
@include breakpoint(md) {
|
||||
@include breakpoint(lg) {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,29 +17,37 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import { Prompt, RouteComponentProps } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import operations from 'web/libs/operations';
|
||||
import { getEditorSessionkey } from 'web/libs/editor';
|
||||
import { getNotePath, notePathDef } from 'web/libs/paths';
|
||||
import { useCleanupEditor, useFocusTextarea } from 'web/libs/hooks/editor';
|
||||
import { useFocusTextarea } from 'web/libs/hooks/editor';
|
||||
import Editor from '../Common/Editor';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import { resetEditor } from '../../store/editor';
|
||||
import { resetEditor, EditorSession } from '../../store/editor';
|
||||
import { createBook } from '../../store/books';
|
||||
import { setMessage } from '../../store/ui';
|
||||
import styles from '../New/New.scss';
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
noteUUID: string;
|
||||
persisted: boolean;
|
||||
editor: EditorSession;
|
||||
setErrMessage: React.Dispatch<string>;
|
||||
}
|
||||
|
||||
const Edit: React.SFC<Props> = ({ noteUUID, history, setErrMessage }) => {
|
||||
const { editor, prevLocation } = useSelector(state => {
|
||||
const Edit: React.SFC<Props> = ({
|
||||
noteUUID,
|
||||
persisted,
|
||||
editor,
|
||||
history,
|
||||
setErrMessage
|
||||
}) => {
|
||||
const { prevLocation } = useSelector(state => {
|
||||
return {
|
||||
editor: state.editor,
|
||||
prevLocation: state.route.prevLocation
|
||||
};
|
||||
});
|
||||
|
|
@ -48,7 +56,6 @@ const Edit: React.SFC<Props> = ({ noteUUID, history, setErrMessage }) => {
|
|||
const textareaRef = useRef(null);
|
||||
|
||||
useFocusTextarea(textareaRef.current);
|
||||
useCleanupEditor();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
|
|
@ -58,6 +65,7 @@ const Edit: React.SFC<Props> = ({ noteUUID, history, setErrMessage }) => {
|
|||
</div>
|
||||
|
||||
<Editor
|
||||
editor={editor}
|
||||
isBusy={submitting}
|
||||
textareaRef={textareaRef}
|
||||
cancelPath={prevLocation}
|
||||
|
|
@ -79,7 +87,7 @@ const Edit: React.SFC<Props> = ({ noteUUID, history, setErrMessage }) => {
|
|||
content: draftContent
|
||||
});
|
||||
|
||||
dispatch(resetEditor());
|
||||
dispatch(resetEditor(editor.sessionKey));
|
||||
|
||||
const dest = getNotePath(note.uuid);
|
||||
history.push(dest);
|
||||
|
|
@ -97,6 +105,8 @@ const Edit: React.SFC<Props> = ({ noteUUID, history, setErrMessage }) => {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Prompt message="You have unsaved changes. Continue?" when={!persisted} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ import { Prompt, RouteComponentProps } from 'react-router-dom';
|
|||
import Helmet from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { getEditorSessionkey } from 'web/libs/editor';
|
||||
import operations from 'web/libs/operations';
|
||||
import Flash from '../Common/Flash';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import { stageNote } from '../../store/editor';
|
||||
import { createSession } from '../../store/editor';
|
||||
import Content from './Content';
|
||||
import styles from '../New/New.scss';
|
||||
|
||||
|
|
@ -36,40 +37,42 @@ interface Match {
|
|||
interface Props extends RouteComponentProps<Match> {}
|
||||
|
||||
const Edit: React.SFC<Props> = ({ match }) => {
|
||||
const { noteUUID } = match.params;
|
||||
|
||||
const sessionKey = getEditorSessionkey(noteUUID);
|
||||
const { editor } = useSelector(state => {
|
||||
return {
|
||||
editor: state.editor
|
||||
};
|
||||
});
|
||||
const session = editor.sessions[sessionKey];
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [errMessage, setErrMessage] = useState('');
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const { noteUUID } = match.params;
|
||||
|
||||
useEffect(() => {
|
||||
operations.notes
|
||||
.fetchOne(noteUUID)
|
||||
.then(note => {
|
||||
dispatch(
|
||||
stageNote({
|
||||
noteUUID: note.uuid,
|
||||
bookUUID: note.book.uuid,
|
||||
bookLabel: note.book.label,
|
||||
content: note.content
|
||||
})
|
||||
);
|
||||
|
||||
setIsReady(true);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setErrMessage(err.message);
|
||||
});
|
||||
}, [dispatch, noteUUID]);
|
||||
if (session === undefined) {
|
||||
operations.notes
|
||||
.fetchOne(noteUUID)
|
||||
.then(note => {
|
||||
dispatch(
|
||||
createSession({
|
||||
noteUUID: note.uuid,
|
||||
bookUUID: note.book.uuid,
|
||||
bookLabel: note.book.label,
|
||||
content: note.content
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setErrMessage(err.message);
|
||||
});
|
||||
}
|
||||
}, [dispatch, noteUUID, session]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="T-edit-page"
|
||||
className={classnames(
|
||||
styles.container,
|
||||
'container mobile-nopadding page page-mobile-full'
|
||||
|
|
@ -83,12 +86,14 @@ const Edit: React.SFC<Props> = ({ match }) => {
|
|||
Error: {errMessage}
|
||||
</Flash>
|
||||
|
||||
{isReady && <Content noteUUID={noteUUID} setErrMessage={setErrMessage} />}
|
||||
|
||||
<Prompt
|
||||
message="You have unsaved changes. Continue?"
|
||||
when={editor.dirty}
|
||||
/>
|
||||
{session !== undefined && (
|
||||
<Content
|
||||
noteUUID={noteUUID}
|
||||
editor={session}
|
||||
persisted={editor.persisted}
|
||||
setErrMessage={setErrMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
z-index: 2;
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-bottom: 1px solid $border-color-light;
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
|
||||
background: $first;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const NormalHeader: React.SFC<Props> = ({ location }) => {
|
|||
<div className={classnames(styles.content)}>
|
||||
<div className={classnames(styles.left)}>
|
||||
<Link
|
||||
id="T-home-link"
|
||||
to={getHomeDest(location, searchObj)}
|
||||
className={styles.brand}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
const Icon = ({ className, ...props }) => {
|
||||
const Icon = ({ className }) => {
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
|
|
@ -26,7 +26,6 @@ const Icon = ({ className, ...props }) => {
|
|||
y="0px"
|
||||
viewBox="0 0 80.13 80.13"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { legacyMigrate } from 'jslib/services/users';
|
||||
import { b64ToBuf, utf8ToBuf, bufToB64 } from 'web/libs/encoding';
|
||||
import * as booksService from 'jslib/services/books';
|
||||
import * as notesService from 'jslib/services/notes';
|
||||
import { legacyFetchNotes } from 'jslib/services/notes';
|
||||
import { updateAuthEmail } from '../../actions/form';
|
||||
import { receiveUser, legacyGetCurrentUser } from '../../actions/auth';
|
||||
import Logo from '../Icons/Logo';
|
||||
import { updateMessage } from '../../actions/ui';
|
||||
import LegacyFooter from '../Common/LegacyFooter';
|
||||
import { aes256GcmEncrypt } from '../../crypto';
|
||||
|
||||
class LegacyEncrypt extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
errorMsg: '',
|
||||
busy: false,
|
||||
isReady: false,
|
||||
progressMsg: ''
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { doLegacyGetCurrentUser } = this.props;
|
||||
|
||||
doLegacyGetCurrentUser().then(() => {
|
||||
const { userData, history } = this.props;
|
||||
|
||||
const user = userData.data;
|
||||
|
||||
if (user.encrypted) {
|
||||
history.push('/app');
|
||||
} else {
|
||||
this.setState({ isReady: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleEncrypt = () => {
|
||||
this.setState({ busy: true }, async () => {
|
||||
try {
|
||||
const cipherKey = localStorage.getItem('cipherKey');
|
||||
const cipherKeyBuf = b64ToBuf(cipherKey);
|
||||
|
||||
const books = await booksService.fetch({ encrypted: false });
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
const book = books[i];
|
||||
const labelBuf = utf8ToBuf(book.label);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const labelEnc = await aes256GcmEncrypt(cipherKeyBuf, labelBuf);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await booksService.update(book.uuid, {
|
||||
name: bufToB64(labelEnc)
|
||||
});
|
||||
}
|
||||
|
||||
const notes = await legacyFetchNotes({ encrypted: false });
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
const note = notes[i];
|
||||
const contentBuf = utf8ToBuf(note.content);
|
||||
|
||||
if (i % 10 === 0) {
|
||||
this.setState({
|
||||
progressMsg: `${i} of ${notes.length} notes encrypted...`
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const contentEnc = await aes256GcmEncrypt(cipherKeyBuf, contentBuf);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await notesService.update(note.uuid, {
|
||||
content: bufToB64(contentEnc)
|
||||
});
|
||||
}
|
||||
|
||||
await legacyMigrate();
|
||||
|
||||
const { history, doUpdateMessage } = this.props;
|
||||
doUpdateMessage(
|
||||
'Congratulations. You are now using encrypted Dnote',
|
||||
'info'
|
||||
);
|
||||
history.push('/');
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
this.setState({ busy: false, errorMsg: e.message, progressMsg: '' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { errorMsg, progressMsg, busy, isReady } = this.state;
|
||||
|
||||
if (!isReady) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page page">
|
||||
<Helmet>
|
||||
<title>Encrypt</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="container">
|
||||
<div className="container">
|
||||
<a href="/">
|
||||
<Logo fill="#252833" width="60" height="60" />
|
||||
</a>
|
||||
<h1 className="heading">Encrypt your notes and books</h1>
|
||||
|
||||
<div className="auth-body">
|
||||
<div className="auth-panel">
|
||||
{errorMsg && (
|
||||
<div className="alert alert-danger">{errorMsg}</div>
|
||||
)}
|
||||
{progressMsg && (
|
||||
<div className="alert alert-info">{progressMsg}</div>
|
||||
)}
|
||||
|
||||
<p>
|
||||
Please press the Encrypt button to encrypt all your notes and
|
||||
books.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={this.handleEncrypt}
|
||||
className="button button-first"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? 'Encrypting...' : 'Encrypt'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LegacyFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
email: state.form.auth.email,
|
||||
userData: state.auth.user
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
doUpdateAuthFormEmail: updateAuthEmail,
|
||||
doReceiveUser: receiveUser,
|
||||
doLegacyGetCurrentUser: legacyGetCurrentUser,
|
||||
doUpdateMessage: updateMessage
|
||||
};
|
||||
|
||||
export default withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LegacyEncrypt)
|
||||
);
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default class JoinForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
password: '',
|
||||
passwordConfirmation: ''
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onJoin, submitting, onEmailChange, email } = this.props;
|
||||
const { password, passwordConfirmation } = this.state;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
onJoin(email, password, passwordConfirmation);
|
||||
}}
|
||||
className="auth-form"
|
||||
>
|
||||
<div className="input-row">
|
||||
<label htmlFor="email-input" className="label">
|
||||
Your Email
|
||||
</label>
|
||||
<input
|
||||
id="email-input"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
|
||||
onEmailChange(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-row">
|
||||
<label htmlFor="password-input" className="label">
|
||||
Create a Password
|
||||
</label>
|
||||
<input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder="●●●●●●●●"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
|
||||
this.setState({ password: val });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-row">
|
||||
<label htmlFor="password-input" className="label">
|
||||
Confirm your Password
|
||||
</label>
|
||||
<input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder="●●●●●●●●"
|
||||
className="form-control"
|
||||
value={passwordConfirmation}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
|
||||
this.setState({ passwordConfirmation: val });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="button button-third button-stretch auth-button"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? <i className="fa fa-spinner fa-spin" /> : 'Confirm'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import JoinForm from './JoinForm';
|
||||
import Logo from '../Icons/Logo';
|
||||
|
||||
import { legacyRegister } from 'jslib/services/users';
|
||||
import { receiveUser, legacyGetCurrentUser } from '../../actions/auth';
|
||||
import { updateAuthEmail } from '../../actions/form';
|
||||
import LegacyFooter from '../Common/LegacyFooter';
|
||||
import { registerHelper } from '../../crypto';
|
||||
import { DEFAULT_KDF_ITERATION } from '../../crypto/consts';
|
||||
|
||||
class LegacyJoin extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
errorMsg: '',
|
||||
submitting: false,
|
||||
isReady: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { doLegacyGetCurrentUser } = this.props;
|
||||
|
||||
doLegacyGetCurrentUser().then(() => {
|
||||
const { userData, history } = this.props;
|
||||
|
||||
const user = userData.data;
|
||||
|
||||
if (!user.legacy) {
|
||||
history.push('/legacy/encrypt');
|
||||
} else {
|
||||
this.setState({ isReady: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleJoin = (email, password, passwordConfirmation) => {
|
||||
const { history } = this.props;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ errorMsg: 'Please enter email' });
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
this.setState({ errorMsg: 'Please enter password' });
|
||||
return;
|
||||
}
|
||||
if (!passwordConfirmation) {
|
||||
this.setState({ errorMsg: 'The passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ submitting: true, errorMsg: '' }, async () => {
|
||||
try {
|
||||
const { cipherKey, cipherKeyEnc, authKey } = await registerHelper({
|
||||
email,
|
||||
password,
|
||||
iteration: DEFAULT_KDF_ITERATION
|
||||
});
|
||||
await legacyRegister({
|
||||
email,
|
||||
authKey,
|
||||
cipherKeyEnc,
|
||||
iteration: DEFAULT_KDF_ITERATION
|
||||
});
|
||||
|
||||
localStorage.setItem('cipherKey', cipherKey);
|
||||
|
||||
history.push('/legacy/encrypt');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
this.setState({ submitting: false, errorMsg: err.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { doUpdateAuthFormEmail, email } = this.props;
|
||||
const { errorMsg, submitting, isReady } = this.state;
|
||||
|
||||
if (!isReady) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<Helmet>
|
||||
<title>Join</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="container">
|
||||
<div className="container">
|
||||
<a href="/">
|
||||
<Logo fill="#252833" width="60" height="60" />
|
||||
</a>
|
||||
<h1 className="heading">Choose your email and password</h1>
|
||||
|
||||
<div className="auth-body">
|
||||
<div className="auth-panel">
|
||||
{errorMsg && (
|
||||
<div className="alert alert-danger">{errorMsg}</div>
|
||||
)}
|
||||
|
||||
<JoinForm
|
||||
onJoin={this.handleJoin}
|
||||
submitting={submitting}
|
||||
onEmailChange={doUpdateAuthFormEmail}
|
||||
email={email}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LegacyFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
email: state.form.auth.email,
|
||||
userData: state.auth.user
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
doUpdateAuthFormEmail: updateAuthEmail,
|
||||
doReceiveUser: receiveUser,
|
||||
doLegacyGetCurrentUser: legacyGetCurrentUser
|
||||
};
|
||||
|
||||
export default withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LegacyJoin)
|
||||
);
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default class LoginForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
password: ''
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { email, onLogin, onEmailChange, submitting } = this.props;
|
||||
const { password } = this.state;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
onLogin(email, password);
|
||||
}}
|
||||
className="auth-form"
|
||||
>
|
||||
<div className="input-row">
|
||||
<label htmlFor="email-input" className="label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email-input"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
|
||||
onEmailChange(val);
|
||||
}}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-row">
|
||||
<div className="label-row">
|
||||
<label htmlFor="password-input" className="label">
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder="●●●●●●●●"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
|
||||
this.setState({ password: val });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="button button-first button-stretch auth-button"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? <i className="fa fa-spinner fa-spin" /> : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { handleLogin } from 'jslib/helpers/auth';
|
||||
import google from '../../img/google.png';
|
||||
import github from '../../img/github.png';
|
||||
|
||||
export default class OauthLoginButton extends React.Component {
|
||||
getLogo = () => {
|
||||
const { provider } = this.props;
|
||||
|
||||
switch (provider) {
|
||||
case 'github':
|
||||
return github;
|
||||
case 'gplus':
|
||||
return google;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { referrer, provider, text } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="button oauth-button"
|
||||
onClick={() => {
|
||||
handleLogin({ provider, referrer });
|
||||
}}
|
||||
>
|
||||
<span className="oauth-button-content">
|
||||
<img src={this.getLogo()} alt={provider} className="provider-logo" />
|
||||
<span className="oauth-text">{text}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getReferrer } from 'jslib/helpers/url';
|
||||
import OauthLoginButton from './OauthLoginButton';
|
||||
import LoginForm from './LoginForm';
|
||||
import LegacyFooter from '../Common/LegacyFooter';
|
||||
|
||||
import Logo from '../Icons/Logo';
|
||||
import { legacySignin } from 'jslib/services/users';
|
||||
import { receiveUser } from '../../actions/auth';
|
||||
import { updateAuthEmail } from '../../actions/form';
|
||||
|
||||
import './module.scss';
|
||||
|
||||
class LegacyLogin extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
errorMsg: '',
|
||||
submitting: false
|
||||
};
|
||||
}
|
||||
|
||||
handlePasswordLogin = (email, password) => {
|
||||
if (!email) {
|
||||
this.setState({ errorMsg: 'Please enter email' });
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
this.setState({ errorMsg: 'Please enter password' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ submitting: true, errorMsg: '' }, () => {
|
||||
legacySignin({ email, password })
|
||||
.then(res => {
|
||||
const { history, doReceiveUser } = this.props;
|
||||
const { user } = res;
|
||||
|
||||
doReceiveUser(user);
|
||||
|
||||
history.push('/legacy/register');
|
||||
})
|
||||
.catch(err => {
|
||||
this.setState({ submitting: false, errorMsg: err.message });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { location, doUpdateAuthFormEmail, email } = this.props;
|
||||
const { submitting, errorMsg } = this.state;
|
||||
|
||||
const referrer = getReferrer(location);
|
||||
|
||||
return (
|
||||
<div className="auth-page login-page">
|
||||
<Helmet>
|
||||
<title>Legacy Login</title>
|
||||
</Helmet>
|
||||
<div className="container">
|
||||
<Link to="/">
|
||||
<Logo fill="#252833" width="60" height="60" />
|
||||
</Link>
|
||||
<h1 className="heading">Sign into new Dnote</h1>
|
||||
|
||||
<div className="auth-body">
|
||||
<div className="auth-panel">
|
||||
<OauthLoginButton
|
||||
referrer={referrer}
|
||||
provider="github"
|
||||
text="Sign in with GitHub"
|
||||
/>
|
||||
<OauthLoginButton
|
||||
referrer={referrer}
|
||||
provider="gplus"
|
||||
text="Sign in with Google"
|
||||
/>
|
||||
|
||||
<div className="divider-text">or</div>
|
||||
|
||||
{errorMsg && <div className="alert alert-danger">{errorMsg}</div>}
|
||||
|
||||
<LoginForm
|
||||
email={email}
|
||||
onLogin={this.handlePasswordLogin}
|
||||
submitting={submitting}
|
||||
onEmailChange={doUpdateAuthFormEmail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LegacyFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
email: state.form.auth.email
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
doUpdateAuthFormEmail: updateAuthEmail,
|
||||
doReceiveUser: receiveUser
|
||||
};
|
||||
|
||||
export default withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LegacyLogin)
|
||||
);
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.auth-page {
|
||||
background: #f3f3f3;
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
padding: 50px 0;
|
||||
|
||||
.logo {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: #252833;
|
||||
font-size: 2.7rem;
|
||||
font-weight: 300;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.referrer-alert {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.auth-body {
|
||||
max-width: 420px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.auth-footer {
|
||||
margin-top: 20px;
|
||||
|
||||
.auth-callout {
|
||||
color: #7c7c7c;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.auth-cta {
|
||||
color: #5c7ded;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
border: 1px solid #ddd;
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.oauth-button {
|
||||
border: 1px solid #ddd;
|
||||
color: #6a6a6a;
|
||||
width: 100%;
|
||||
justify-content: inherit;
|
||||
background: white;
|
||||
|
||||
&:hover {
|
||||
background: #fefefe;
|
||||
box-shadow: 0 0 4px 2px #f3f3f3;
|
||||
}
|
||||
|
||||
& + .oauth-button {
|
||||
margin-top: 8px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.oauth-button-content {
|
||||
display: flex;
|
||||
// width: 100%;
|
||||
}
|
||||
|
||||
.oauth-text {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
position: relative;
|
||||
color: #ababab;
|
||||
font-size: 1.4rem;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
padding: 12px 0;
|
||||
margin-top: 5px;
|
||||
|
||||
&::before {
|
||||
margin-left: -52%;
|
||||
text-align: right;
|
||||
width: 50%;
|
||||
top: 51%;
|
||||
overflow: hidden;
|
||||
height: 1px;
|
||||
background-color: #d0d0d0;
|
||||
content: '\a0';
|
||||
position: absolute;
|
||||
}
|
||||
&::after {
|
||||
margin-left: 2%;
|
||||
width: 50%;
|
||||
top: 51%;
|
||||
overflow: hidden;
|
||||
height: 1px;
|
||||
background-color: #d0d0d0;
|
||||
content: '\a0';
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.auth-form {
|
||||
text-align: left;
|
||||
}
|
||||
.input-row {
|
||||
& ~ .input-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// specific to '/login page'
|
||||
.login-page {
|
||||
.label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.reset-cta {
|
||||
font-size: 1.3rem;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.legacy-login-footer {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
151
web/src/components/New/Content.tsx
Normal file
151
web/src/components/New/Content.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, Fragment } from 'react';
|
||||
import { Prompt, RouteComponentProps } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import Helmet from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { focusTextarea } from 'web/libs/dom';
|
||||
import { getEditorSessionkey } from 'web/libs/editor';
|
||||
import operations from 'web/libs/operations';
|
||||
import { getNotePath, notePathDef } from 'web/libs/paths';
|
||||
import { useFocus } from 'web/libs/hooks/dom';
|
||||
import Editor from '../Common/Editor';
|
||||
import Flash from '../Common/Flash';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import { resetEditor, createSession, EditorSession } from '../../store/editor';
|
||||
import { createBook } from '../../store/books';
|
||||
import { setMessage } from '../../store/ui';
|
||||
import PayWall from '../Common/PayWall';
|
||||
import styles from './New.scss';
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
editor: EditorSession;
|
||||
persisted: boolean;
|
||||
}
|
||||
|
||||
// useInitFocus initializes the focus on HTML elements depending on the current
|
||||
// state of the editor.
|
||||
function useInitFocus({ bookLabel, content, textareaRef, setTriggerFocus }) {
|
||||
useEffect(() => {
|
||||
if (!bookLabel && !content) {
|
||||
setTriggerFocus();
|
||||
} else {
|
||||
const textareaEl = textareaRef.current;
|
||||
|
||||
if (textareaEl) {
|
||||
focusTextarea(textareaEl);
|
||||
}
|
||||
}
|
||||
}, [setTriggerFocus, bookLabel, textareaRef]);
|
||||
}
|
||||
|
||||
const New: React.SFC<Props> = ({ editor, persisted, history }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [errMessage, setErrMessage] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const [setTriggerFocus, triggerRef] = useFocus();
|
||||
|
||||
useInitFocus({
|
||||
bookLabel: editor.bookLabel,
|
||||
content: editor.content,
|
||||
textareaRef,
|
||||
setTriggerFocus
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Helmet>
|
||||
<title>New</title>
|
||||
</Helmet>
|
||||
|
||||
<PayWall>
|
||||
<div
|
||||
className={classnames(
|
||||
styles.container,
|
||||
'container mobile-nopadding page page-mobile-full'
|
||||
)}
|
||||
>
|
||||
<Flash kind="danger" when={Boolean(errMessage)}>
|
||||
Error: {errMessage}
|
||||
</Flash>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<div className={classnames(styles.overlay, {})} />
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.heading}>New note</h2>
|
||||
</div>
|
||||
|
||||
<Editor
|
||||
isNew
|
||||
editor={editor}
|
||||
isBusy={submitting}
|
||||
textareaRef={textareaRef}
|
||||
bookSelectorTriggerRef={triggerRef}
|
||||
onSubmit={async ({ draftContent, draftBookUUID }) => {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
let bookUUID;
|
||||
|
||||
if (!draftBookUUID) {
|
||||
const book = await dispatch(createBook(editor.bookLabel));
|
||||
bookUUID = book.uuid;
|
||||
} else {
|
||||
bookUUID = draftBookUUID;
|
||||
}
|
||||
|
||||
const res = await operations.notes.create({
|
||||
bookUUID,
|
||||
content: draftContent
|
||||
});
|
||||
|
||||
dispatch(resetEditor(editor.sessionKey));
|
||||
|
||||
const dest = getNotePath(res.result.uuid);
|
||||
history.push(dest);
|
||||
|
||||
dispatch(
|
||||
setMessage({
|
||||
message: 'Created a note',
|
||||
kind: 'info',
|
||||
path: notePathDef
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setErrMessage(err.message);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Prompt
|
||||
message="You have unsaved changes. Continue?"
|
||||
when={!persisted}
|
||||
/>
|
||||
</PayWall>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(withRouter(New));
|
||||
|
|
@ -17,60 +17,68 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, Fragment } from 'react';
|
||||
import { Prompt, RouteComponentProps } from 'react-router-dom';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import Helmet from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { focusTextarea } from 'web/libs/dom';
|
||||
import { getEditorSessionkey } from 'web/libs/editor';
|
||||
import operations from 'web/libs/operations';
|
||||
import { getNotePath, notePathDef } from 'web/libs/paths';
|
||||
import { useCleanupEditor } from 'web/libs/hooks/editor';
|
||||
import { useFocus } from 'web/libs/hooks/dom';
|
||||
import Editor from '../Common/Editor';
|
||||
import Flash from '../Common/Flash';
|
||||
import { useDispatch, useSelector } from '../../store';
|
||||
import { resetEditor } from '../../store/editor';
|
||||
import { resetEditor, createSession } from '../../store/editor';
|
||||
import { createBook } from '../../store/books';
|
||||
import { setMessage } from '../../store/ui';
|
||||
import PayWall from '../Common/PayWall';
|
||||
import Content from './Content';
|
||||
import styles from './New.scss';
|
||||
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
// useInitFocus initializes the focus on HTML elements depending on the current
|
||||
// state of the editor.
|
||||
function useInitFocus({ bookLabel, textareaRef, setTriggerFocus }) {
|
||||
function useInitFocus({ bookLabel, content, textareaRef, setTriggerFocus }) {
|
||||
useEffect(() => {
|
||||
if (!bookLabel) {
|
||||
if (!bookLabel && !content) {
|
||||
setTriggerFocus();
|
||||
} else {
|
||||
const textareaEl = textareaRef.current;
|
||||
|
||||
if (textareaEl) {
|
||||
textareaEl.focus();
|
||||
focusTextarea(textareaEl);
|
||||
}
|
||||
}
|
||||
}, [setTriggerFocus, bookLabel, textareaRef]);
|
||||
}
|
||||
|
||||
const New: React.SFC<Props> = ({ history }) => {
|
||||
const sessionKey = getEditorSessionkey(null);
|
||||
const { editor } = useSelector(state => {
|
||||
return {
|
||||
editor: state.editor
|
||||
};
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const [errMessage, setErrMessage] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const [setTriggerFocus, triggerRef] = useFocus();
|
||||
|
||||
useCleanupEditor();
|
||||
useInitFocus({
|
||||
bookLabel: editor.bookLabel,
|
||||
textareaRef,
|
||||
setTriggerFocus
|
||||
});
|
||||
const session = editor.sessions[sessionKey];
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
// if there is no editorSesssion session, create one
|
||||
if (session === undefined) {
|
||||
dispatch(
|
||||
createSession({
|
||||
noteUUID: null,
|
||||
bookUUID: null,
|
||||
bookLabel: null,
|
||||
content: ''
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [dispatch, session]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
@ -78,72 +86,9 @@ const New: React.SFC<Props> = ({ history }) => {
|
|||
<title>New</title>
|
||||
</Helmet>
|
||||
|
||||
<PayWall>
|
||||
<div
|
||||
className={classnames(
|
||||
styles.container,
|
||||
'container mobile-nopadding page page-mobile-full'
|
||||
)}
|
||||
>
|
||||
<Flash kind="danger" when={Boolean(errMessage)}>
|
||||
Error: {errMessage}
|
||||
</Flash>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<div className={classnames(styles.overlay, {})} />
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.heading}>New note</h2>
|
||||
</div>
|
||||
|
||||
<Editor
|
||||
isNew
|
||||
isBusy={submitting}
|
||||
textareaRef={textareaRef}
|
||||
bookSelectorTriggerRef={triggerRef}
|
||||
onSubmit={async ({ draftContent, draftBookUUID }) => {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
let bookUUID;
|
||||
|
||||
if (!draftBookUUID) {
|
||||
const book = await dispatch(createBook(editor.bookLabel));
|
||||
bookUUID = book.uuid;
|
||||
} else {
|
||||
bookUUID = draftBookUUID;
|
||||
}
|
||||
|
||||
const res = await operations.notes.create({
|
||||
bookUUID,
|
||||
content: draftContent
|
||||
});
|
||||
|
||||
dispatch(resetEditor());
|
||||
|
||||
const dest = getNotePath(res.result.uuid);
|
||||
history.push(dest);
|
||||
|
||||
dispatch(
|
||||
setMessage({
|
||||
message: 'Created a note',
|
||||
kind: 'info',
|
||||
path: notePathDef
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setErrMessage(err.message);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Prompt
|
||||
message="You have unsaved changes. Continue?"
|
||||
when={editor.dirty}
|
||||
/>
|
||||
</div>
|
||||
</PayWall>
|
||||
{session !== undefined && (
|
||||
<Content editor={session} persisted={editor.persisted} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import { Redirect, RouteComponentProps } from 'react-router-dom';
|
||||
import hoistNonReactStatics from 'hoist-non-react-statics';
|
||||
|
||||
import { getReferrer } from 'jslib//helpers/url';
|
||||
import { RemoteData } from '../store';
|
||||
|
|
@ -39,9 +38,7 @@ function renderFallback(referrer?: string) {
|
|||
// guestOnly returns a HOC that renders the given component only if user is not
|
||||
// logged in
|
||||
export default function(Component: React.ComponentType): React.ComponentType {
|
||||
interface Props extends RouteComponentProps {
|
||||
userData: RemoteData<UserData>;
|
||||
}
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
const HOC: React.SFC<Props> = props => {
|
||||
const { location } = props;
|
||||
|
|
@ -62,8 +59,5 @@ export default function(Component: React.ComponentType): React.ComponentType {
|
|||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
// Copy over static methods
|
||||
hoistNonReactStatics(HOC, Component);
|
||||
|
||||
return HOC;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,23 +20,27 @@ import React from 'react';
|
|||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import hoistNonReactStatics from 'hoist-non-react-statics';
|
||||
|
||||
import { getPathFromLocation } from 'jslib//helpers/url';
|
||||
import { AppState, RemoteData } from '../store';
|
||||
import { UserData } from '../store/auth';
|
||||
import { useSelector } from '../store';
|
||||
|
||||
// userOnly returns a HOC that redirects to Login page if user is not logged in
|
||||
export default function(
|
||||
Component: React.ComponentType,
|
||||
guestPath: string = '/login'
|
||||
) {
|
||||
interface Props extends RouteComponentProps {
|
||||
userData: RemoteData<UserData>;
|
||||
}
|
||||
interface Props extends RouteComponentProps {}
|
||||
|
||||
const HOC: React.SFC<Props> = props => {
|
||||
const { userData, location } = props;
|
||||
const { location } = props;
|
||||
|
||||
const { userData } = useSelector(state => {
|
||||
return {
|
||||
userData: state.auth.user
|
||||
};
|
||||
});
|
||||
|
||||
const isGuest = userData.isFetched && !userData.data.uuid;
|
||||
if (isGuest) {
|
||||
|
|
@ -50,14 +54,5 @@ export default function(
|
|||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
// Copy over static methods
|
||||
hoistNonReactStatics(HOC, Component);
|
||||
|
||||
function mapStateToProps(state: AppState) {
|
||||
return {
|
||||
userData: state.auth.user
|
||||
};
|
||||
}
|
||||
|
||||
return withRouter(connect(mapStateToProps)(HOC));
|
||||
return withRouter(HOC);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,22 +16,16 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// SafeLink is a link that pushes a new location to the history stack
|
||||
// only if the current editor is not dirty.
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
// sessionKeyNew is the editor session key for a new note
|
||||
const sessionKeyNew = 'new';
|
||||
|
||||
import { decorate } from './internal';
|
||||
// getEditorSessionkey returns a unique editor session key for the given noteUUID.
|
||||
// If the noteUUID is null, it returns a session key for the new note.
|
||||
// Editor session holds an editor state for a particular note.
|
||||
export function getEditorSessionkey(noteUUID: string | null): string {
|
||||
if (noteUUID === null) {
|
||||
return sessionKeyNew;
|
||||
}
|
||||
|
||||
function SafeLink({ to, onClick, className, children }) {
|
||||
const Decorated = decorate(Link, {
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
children
|
||||
});
|
||||
|
||||
return <Decorated />;
|
||||
return noteUUID;
|
||||
}
|
||||
|
||||
export default SafeLink;
|
||||
49
web/src/libs/editor_test.ts
Normal file
49
web/src/libs/editor_test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
|
||||
import { getEditorSessionkey } from './editor';
|
||||
|
||||
describe('editor.ts', () => {
|
||||
describe('getEditorSessionkey', () => {
|
||||
const testCases = [
|
||||
{
|
||||
noteUUID: null,
|
||||
expected: 'new'
|
||||
},
|
||||
{
|
||||
noteUUID: '0ad88090-ab44-4432-be80-09c033f4c582',
|
||||
expected: '0ad88090-ab44-4432-be80-09c033f4c582'
|
||||
},
|
||||
{
|
||||
noteUUID: '6c20d136-8a15-443b-bd58-d2d963d38938',
|
||||
expected: '6c20d136-8a15-443b-bd58-d2d963d38938'
|
||||
}
|
||||
];
|
||||
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const tc = testCases[i];
|
||||
|
||||
it(`generates a session key for input: ${tc.noteUUID}`, () => {
|
||||
const result = getEditorSessionkey(tc.noteUUID);
|
||||
expect(result).to.equal;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -19,202 +19,204 @@
|
|||
import { expect } from 'chai';
|
||||
import { TokenKind, tokenize, scanToken } from './lexer';
|
||||
|
||||
describe('scanToken', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'foo bar',
|
||||
idx: 1,
|
||||
retTok: { value: 'o', kind: TokenKind.char },
|
||||
retIdx: 2
|
||||
},
|
||||
{
|
||||
input: 'foo bar',
|
||||
idx: 6,
|
||||
retTok: { value: 'r', kind: TokenKind.char },
|
||||
retIdx: -1
|
||||
},
|
||||
{
|
||||
input: 'foo <bar>',
|
||||
idx: 4,
|
||||
retTok: { value: '<', kind: TokenKind.char },
|
||||
retIdx: 5
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehL>',
|
||||
idx: 4,
|
||||
retTok: { value: '<', kind: TokenKind.char },
|
||||
retIdx: 5
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> foo bar',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 13
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> <dnotehl>foo</dnotehl> bar',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 13
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> <dnotehl>foo</dnotehl> bar',
|
||||
idx: 27,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 36
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> foo bar',
|
||||
idx: 13,
|
||||
retTok: { value: 'b', kind: TokenKind.char },
|
||||
retIdx: 14
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> foo bar',
|
||||
idx: 16,
|
||||
retTok: { kind: TokenKind.hlEnd },
|
||||
retIdx: 26
|
||||
},
|
||||
{
|
||||
input: '<dno<dnotehl>tehl>',
|
||||
idx: 0,
|
||||
retTok: { value: '<', kind: TokenKind.char },
|
||||
retIdx: 1
|
||||
},
|
||||
{
|
||||
input: '<dno<dnotehl>tehl>',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 13
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl>',
|
||||
idx: 16,
|
||||
retTok: { kind: TokenKind.hlEnd },
|
||||
retIdx: -1
|
||||
},
|
||||
// user writes reserved token
|
||||
{
|
||||
input: 'foo <dnotehl>',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: -1
|
||||
describe('lexer.ts', () => {
|
||||
describe('scanToken', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'foo bar',
|
||||
idx: 1,
|
||||
retTok: { value: 'o', kind: TokenKind.char },
|
||||
retIdx: 2
|
||||
},
|
||||
{
|
||||
input: 'foo bar',
|
||||
idx: 6,
|
||||
retTok: { value: 'r', kind: TokenKind.char },
|
||||
retIdx: -1
|
||||
},
|
||||
{
|
||||
input: 'foo <bar>',
|
||||
idx: 4,
|
||||
retTok: { value: '<', kind: TokenKind.char },
|
||||
retIdx: 5
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehL>',
|
||||
idx: 4,
|
||||
retTok: { value: '<', kind: TokenKind.char },
|
||||
retIdx: 5
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> foo bar',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 13
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> <dnotehl>foo</dnotehl> bar',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 13
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> <dnotehl>foo</dnotehl> bar',
|
||||
idx: 27,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 36
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> foo bar',
|
||||
idx: 13,
|
||||
retTok: { value: 'b', kind: TokenKind.char },
|
||||
retIdx: 14
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl> foo bar',
|
||||
idx: 16,
|
||||
retTok: { kind: TokenKind.hlEnd },
|
||||
retIdx: 26
|
||||
},
|
||||
{
|
||||
input: '<dno<dnotehl>tehl>',
|
||||
idx: 0,
|
||||
retTok: { value: '<', kind: TokenKind.char },
|
||||
retIdx: 1
|
||||
},
|
||||
{
|
||||
input: '<dno<dnotehl>tehl>',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: 13
|
||||
},
|
||||
{
|
||||
input: 'foo <dnotehl>bar</dnotehl>',
|
||||
idx: 16,
|
||||
retTok: { kind: TokenKind.hlEnd },
|
||||
retIdx: -1
|
||||
},
|
||||
// user writes reserved token
|
||||
{
|
||||
input: 'foo <dnotehl>',
|
||||
idx: 4,
|
||||
retTok: { kind: TokenKind.hlBegin },
|
||||
retIdx: -1
|
||||
}
|
||||
];
|
||||
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const tc = testCases[i];
|
||||
|
||||
it(`scans ${tc.input}`, () => {
|
||||
const result = scanToken(tc.idx, tc.input);
|
||||
|
||||
expect(result.tok).to.deep.equal(tc.retTok);
|
||||
});
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const tc = testCases[i];
|
||||
describe('tokenize', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'ab<dnotehl>c</dnotehl>',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'a'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'b'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'c'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: 'ab<dnotehl>c</dnotehl>d',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'a'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'b'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'c'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'd'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
},
|
||||
// user writes a reserved token
|
||||
{
|
||||
input: '<dnotehl><dnotehl></dnotehl>',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: '<dnotehl></dnotehl></dnotehl>',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
it(`scans ${tc.input}`, () => {
|
||||
const result = scanToken(tc.idx, tc.input);
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const tc = testCases[i];
|
||||
|
||||
expect(result.tok).to.deep.equal(tc.retTok);
|
||||
});
|
||||
}
|
||||
});
|
||||
it(`tokenizes ${tc.input}`, () => {
|
||||
const result = tokenize(tc.input);
|
||||
|
||||
describe('tokenize', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'ab<dnotehl>c</dnotehl>',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'a'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'b'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'c'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: 'ab<dnotehl>c</dnotehl>d',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'a'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'b'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'c'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.char,
|
||||
value: 'd'
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
},
|
||||
// user writes a reserved token
|
||||
{
|
||||
input: '<dnotehl><dnotehl></dnotehl>',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
input: '<dnotehl></dnotehl></dnotehl>',
|
||||
tokens: [
|
||||
{
|
||||
kind: TokenKind.hlBegin
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.hlEnd
|
||||
},
|
||||
{
|
||||
kind: TokenKind.eol
|
||||
}
|
||||
]
|
||||
expect(result).to.deep.equal(tc.tokens);
|
||||
});
|
||||
}
|
||||
];
|
||||
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const tc = testCases[i];
|
||||
|
||||
it(`tokenizes ${tc.input}`, () => {
|
||||
const result = tokenize(tc.input);
|
||||
|
||||
expect(result).to.deep.equal(tc.tokens);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,14 +31,3 @@ export function useFocusTextarea(textareaEl: HTMLTextAreaElement) {
|
|||
}
|
||||
}, [textareaEl]);
|
||||
}
|
||||
|
||||
// useCleanupEditor is a hook that cleans up the editor state
|
||||
export function useCleanupEditor() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch(resetEditor());
|
||||
};
|
||||
}, [dispatch]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,15 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const stateKey = 'state';
|
||||
import { AppState } from '../store';
|
||||
|
||||
// stateKey is the key under which the application state is persisted. It is
|
||||
// versioned to accommodate any backward incomptaible changes to the store.
|
||||
const stateKey = 'state-v0';
|
||||
|
||||
// loadState parses the serialized state tree stored in the localStorage
|
||||
// and returns it
|
||||
export function loadState(): JSON {
|
||||
export function loadState(): Partial<AppState> {
|
||||
try {
|
||||
const serialized = localStorage.getItem(stateKey);
|
||||
|
||||
|
|
@ -36,7 +40,7 @@ export function loadState(): JSON {
|
|||
}
|
||||
|
||||
// saveState writes the given state to localStorage
|
||||
export function saveState(state: object) {
|
||||
export function saveState(state: Partial<AppState>) {
|
||||
try {
|
||||
const serialized = JSON.stringify(state);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,60 +23,69 @@ import {
|
|||
FLUSH_CONTENT,
|
||||
UPDATE_BOOK,
|
||||
RESET,
|
||||
STAGE_NOTE,
|
||||
MARK_DIRTY,
|
||||
MarkDirtyAction,
|
||||
StageNoteAction,
|
||||
CREATE_SESSION,
|
||||
MARK_PERSISTED,
|
||||
MarkPersistedAction,
|
||||
CreateSessionAction,
|
||||
FlushContentAction,
|
||||
UpdateBookAction,
|
||||
ResetAction
|
||||
} from './type';
|
||||
|
||||
export function stageNote({
|
||||
export function createSession({
|
||||
noteUUID,
|
||||
bookUUID,
|
||||
bookLabel,
|
||||
content
|
||||
}): StageNoteAction {
|
||||
}): CreateSessionAction {
|
||||
return {
|
||||
type: STAGE_NOTE,
|
||||
type: CREATE_SESSION,
|
||||
data: { noteUUID, bookUUID, bookLabel, content }
|
||||
};
|
||||
}
|
||||
|
||||
export function flushContent(content): FlushContentAction {
|
||||
export function flushContent(
|
||||
sessionKey: string,
|
||||
content: string
|
||||
): FlushContentAction {
|
||||
return {
|
||||
type: FLUSH_CONTENT,
|
||||
data: { content }
|
||||
data: { sessionKey, content }
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateBookActionParam {
|
||||
sessionKey: string;
|
||||
uuid: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function updateBook({
|
||||
sessionKey,
|
||||
uuid,
|
||||
label
|
||||
}: UpdateBookActionParam): UpdateBookAction {
|
||||
return {
|
||||
type: UPDATE_BOOK,
|
||||
data: {
|
||||
sessionKey,
|
||||
uuid,
|
||||
label
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetEditor(): ResetAction {
|
||||
export function resetEditor(sessionKey: string): ResetAction {
|
||||
return {
|
||||
type: RESET
|
||||
type: RESET,
|
||||
data: {
|
||||
sessionKey
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function markDirty(): MarkDirtyAction {
|
||||
export function markPersisted(): MarkPersistedAction {
|
||||
return {
|
||||
type: MARK_DIRTY
|
||||
type: MARK_PERSISTED
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,22 +16,32 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { removeKey } from 'jslib/helpers/obj';
|
||||
import { getEditorSessionkey } from 'web/libs/editor';
|
||||
import {
|
||||
EditorState,
|
||||
EditorSession,
|
||||
EditorActionType,
|
||||
FLUSH_CONTENT,
|
||||
UPDATE_BOOK,
|
||||
RESET,
|
||||
STAGE_NOTE,
|
||||
MARK_DIRTY
|
||||
CREATE_SESSION,
|
||||
MARK_PERSISTED
|
||||
} from './type';
|
||||
|
||||
function makeSession(key: string): EditorSession {
|
||||
return {
|
||||
sessionKey: key,
|
||||
noteUUID: null,
|
||||
bookUUID: null,
|
||||
bookLabel: null,
|
||||
content: ''
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: EditorState = {
|
||||
noteUUID: null,
|
||||
bookUUID: null,
|
||||
bookLabel: null,
|
||||
content: '',
|
||||
dirty: false
|
||||
persisted: false,
|
||||
sessions: {}
|
||||
};
|
||||
|
||||
export default function(
|
||||
|
|
@ -39,38 +49,71 @@ export default function(
|
|||
action: EditorActionType
|
||||
): EditorState {
|
||||
switch (action.type) {
|
||||
case STAGE_NOTE: {
|
||||
case CREATE_SESSION: {
|
||||
const { data } = action;
|
||||
|
||||
const sessionKey = getEditorSessionkey(data.noteUUID);
|
||||
|
||||
return {
|
||||
...state,
|
||||
noteUUID: action.data.noteUUID,
|
||||
bookUUID: action.data.bookUUID,
|
||||
bookLabel: action.data.bookLabel,
|
||||
content: action.data.content,
|
||||
dirty: false
|
||||
persisted: false,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[sessionKey]: {
|
||||
sessionKey,
|
||||
noteUUID: data.noteUUID,
|
||||
bookUUID: data.bookUUID,
|
||||
bookLabel: data.bookLabel,
|
||||
content: data.content
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
case FLUSH_CONTENT: {
|
||||
const { data } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
content: action.data.content,
|
||||
dirty: true
|
||||
persisted: false,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[data.sessionKey]: {
|
||||
...state.sessions[data.sessionKey],
|
||||
content: data.content
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
case UPDATE_BOOK: {
|
||||
const { data } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
bookUUID: action.data.uuid,
|
||||
bookLabel: action.data.label
|
||||
persisted: false,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[data.sessionKey]: {
|
||||
...state.sessions[data.sessionKey],
|
||||
bookUUID: action.data.uuid,
|
||||
bookLabel: action.data.label
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
case MARK_DIRTY: {
|
||||
case MARK_PERSISTED: {
|
||||
return {
|
||||
...state,
|
||||
dirty: true
|
||||
persisted: true
|
||||
};
|
||||
}
|
||||
case RESET: {
|
||||
return initialState;
|
||||
const { data } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
persisted: false,
|
||||
sessions: removeKey(state.sessions, data.sessionKey)
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -16,26 +16,33 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface EditorState {
|
||||
export interface EditorSession {
|
||||
sessionKey: string;
|
||||
noteUUID: string | null;
|
||||
bookUUID: string | null;
|
||||
bookLabel: string | null;
|
||||
content: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export const MARK_DIRTY = 'editor/MARK_DIRTY';
|
||||
export const STAGE_NOTE = 'editor/STAGE_NOTE';
|
||||
export interface EditorState {
|
||||
persisted: boolean;
|
||||
sessions: {
|
||||
[key: string]: EditorSession;
|
||||
};
|
||||
}
|
||||
|
||||
export const MARK_PERSISTED = 'editor/MARK_PERSISTED';
|
||||
export const CREATE_SESSION = 'editor/CREATE_SESSION';
|
||||
export const FLUSH_CONTENT = 'editor/FLUSH_CONTENT';
|
||||
export const UPDATE_BOOK = 'editor/UPDATE_BOOK';
|
||||
export const RESET = 'editor/RESET';
|
||||
|
||||
export interface MarkDirtyAction {
|
||||
type: typeof MARK_DIRTY;
|
||||
export interface MarkPersistedAction {
|
||||
type: typeof MARK_PERSISTED;
|
||||
}
|
||||
|
||||
export interface StageNoteAction {
|
||||
type: typeof STAGE_NOTE;
|
||||
export interface CreateSessionAction {
|
||||
type: typeof CREATE_SESSION;
|
||||
data: {
|
||||
noteUUID: string;
|
||||
bookUUID: string;
|
||||
|
|
@ -47,6 +54,7 @@ export interface StageNoteAction {
|
|||
export interface FlushContentAction {
|
||||
type: typeof FLUSH_CONTENT;
|
||||
data: {
|
||||
sessionKey: string;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -54,6 +62,7 @@ export interface FlushContentAction {
|
|||
export interface UpdateBookAction {
|
||||
type: typeof UPDATE_BOOK;
|
||||
data: {
|
||||
sessionKey: string;
|
||||
uuid: string;
|
||||
label: string;
|
||||
};
|
||||
|
|
@ -61,11 +70,14 @@ export interface UpdateBookAction {
|
|||
|
||||
export interface ResetAction {
|
||||
type: typeof RESET;
|
||||
data: {
|
||||
sessionKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type EditorActionType =
|
||||
| MarkDirtyAction
|
||||
| StageNoteAction
|
||||
| MarkPersistedAction
|
||||
| CreateSessionAction
|
||||
| FlushContentAction
|
||||
| UpdateBookAction
|
||||
| ResetAction;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue