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:
Sung Won Cho 2019-10-02 16:00:33 +08:00 committed by GitHub
commit edfaa3f953
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 700 additions and 1669 deletions

View file

@ -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",

View file

@ -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() {

View file

@ -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>) {

View file

@ -66,10 +66,6 @@
}
}
.trigger-hidden {
visibility: hidden;
}
.book-label {
margin-left: rem(8px);
font-weight: 600;

View file

@ -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

View file

@ -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;

View file

@ -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>

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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);
}

View file

@ -22,7 +22,7 @@
@import '../App/rem';
.wrapper {
@include breakpoint(md) {
@include breakpoint(lg) {
margin-top: rem(20px);
}
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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}
>

View file

@ -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

View file

@ -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)
);

View file

@ -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="&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;"
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="&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;"
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>
);
}
}

View file

@ -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)
);

View file

@ -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="&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;"
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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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)
);

View file

@ -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;
}

View 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));

View file

@ -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>
);
};

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;

View 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;
});
}
});
});

View file

@ -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);
});
}
});
});

View file

@ -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]);
}

View file

@ -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);

View file

@ -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
};
}

View file

@ -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;

View file

@ -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;