initial commit

This commit is contained in:
JJ Kasper
2018-05-17 14:31:05 -05:00
commit 2c90d2e7dd
79 changed files with 10684 additions and 0 deletions

49
comps/AddDoc.js Normal file
View File

@@ -0,0 +1,49 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import fetch from 'isomorphic-unfetch';
import mapUser from '../util/mapUser';
import getUrl from '../util/getUrl';
import getJwt from '../util/getJwt';
const getDoc = async (id, req) => {
let found, doc;
const jwt = getJwt(req);
if(!jwt) return { found, doc, id };
const docRes = await fetch(getUrl('docs/' + id, Boolean(req)), {
method: 'GET',
headers: { Authorization: jwt }
});
if(docRes.ok) {
doc = await docRes.json();
found = true;
}
return { found, doc, id };
};
export default ComposedComponent => {
class DocComp extends Component {
state = {
found: false,
id: null,
doc: {}
}
static async getInitialProps({ query, req }) {
return await getDoc(query.id, req);
}
static getDerivedStateFromProps(nextProps, state) {
const { found, id, doc } = nextProps;
if(state.found !== found) return { found, id, doc };
return null;
}
async componentDidUpdate(prevProps) {
const { user, found, id } = this.props;
if(prevProps.user.email === user.email || found) return;
if(!user.email) return;
this.setState(await getDoc(id));
}
render() {
return <ComposedComponent {...this.state} />;
}
}
return connect(mapUser)(DocComp);
};

65
comps/CodeMirror.js Normal file
View File

@@ -0,0 +1,65 @@
import { Component } from 'react';
import cm from 'codemirror';
import { getKey, isCtrlKey } from '../util/keys';
if(typeof window !== 'undefined') {
require('codemirror/mode/markdown/markdown');
}
export default class CodeMirror extends Component {
handleChange = () => {
if(!this.editor) return;
const value = this.editor.getValue();
if(value !== this.props.value) {
this.props.onChange && this.props.onChange(value);
if(this.editor.getValue() !== this.props.value) {
if(this.state.isControlled) {
this.editor.setValue(this.props.value);
} else {
this.props.value = value;
}
}
}
}
checkSubmit = (cm, e) => {
const key = getKey(e);
if(isCtrlKey(key)) {
this.ctrlKey = true;
} else if(key === 13 && this.ctrlKey) {
this.props.onSubmit();
}
}
handleKeyUp = (cm, e) => {
if(isCtrlKey(getKey(e))) this.ctrlKey = false;
}
componentDidMount() {
if(typeof window === 'undefined') return;
this.editor = cm.fromTextArea(this.textarea, this.props.options);
this.editor.on('change', this.handleChange);
if(typeof this.props.onSubmit === 'function') {
this.editor.on('keydown', this.checkSubmit);
this.editor.on('keyup', this.handleKeyUp);
this.setupSubmitKey = true;
}
}
componentWillUnmount() {
if(this.setupSubmitKey) {
this.editor.off('keydown', this.checkSubmit);
this.editor.off('keyup', this.handleKeyUp);
this.setupSubmitKey = false;
}
}
componentDidUpdate() {
if(!this.editor || !this.props.value) return;
if(this.editor.getValue() !== this.props.value) {
this.editor.setValue(this.props.value);
}
}
render() {
const { value, className, onChange } = this.props;
return (
<div {...{className}}>
<textarea {...{value, onChange}} ref={el => this.textarea = el} />
</div>
);
}
}

20
comps/DocItem.js Normal file
View File

@@ -0,0 +1,20 @@
import Link from 'next/link';
import getUrl from '../util/getUrl';
const DocItem = ({ id, name, dir, updated }) => {
name = dir + (dir.length > 0 ? '/' : '') + name;
const as = getUrl('k/' + id);
const href = { pathname: '/k', query: { id } };
return (<tr>
<td><Link {...{href, as}}>
<a><p className='noMargin'>
{ name }
<span className='float-right'>
{new Date(updated).toLocaleDateString('en-US')}
</span>
</p></a>
</Link></td>
</tr>);
};
export default DocItem;

10
comps/Footer.js Normal file
View File

@@ -0,0 +1,10 @@
const Footer = () => (
<footer className='footer'>
<p>
Powered by{' '}
<a href='//github.com/ijjk/mykb' target='_blank' rel='noopener noreferrer'>MYKB</a>
</p>
</footer>
);
export default Footer;

75
comps/Header.js Normal file
View File

@@ -0,0 +1,75 @@
import React, { Component } from 'react';
import { withRouter } from 'next/router';
import { connect } from 'react-redux';
import { doLogout } from '../redux/actions/userAct';
import Link from 'next/link';
import getUrl from '../util/getUrl';
import mapUser from '../util/mapUser';
const NavLink = ({ children, href, active }) => {
const activeClass = active ? ' active' : '';
return (
<Link href={href} as={getUrl(href)}>
<a className={activeClass}>{children}</a>
</Link>
);
};
const navItems = [
['/', 'Home'],
['/new', 'New Doc'],
['/settings', 'Settings']
];
class Header extends Component {
state = {
open: false
}
hideNav = () => this.setState({ open: false });
toggleNav = () => this.setState({
open: !this.state.open
});
isActive = url => (
getUrl(this.props.router.pathname) === getUrl(url)
);
logout = e => {
e.preventDefault();
this.hideNav();
doLogout();
};
render() {
const expandClass = this.state.open ? ' active' : '';
const { user } = this.props;
return(
<nav className='navbar' role='navigation' aria-label='main navigation'>
<div className='navbar-brand'>
<NavLink href='/'>
<h3 onClick={this.hideNav}>MYKB</h3>
</NavLink>
</div>
{!user.email ? null
: [
<div className={'navbar-burger ' + expandClass}
onClick={this.toggleNav} key='burger'
>
<div /><div /><div />
</div>,
<div className={'navbar-items' + expandClass} key='items'>
{navItems.map(item => (
<NavLink key={item[0]} href={item[0]}
active={this.isActive(item[0])}
>
<p className='item' onClick={this.hideNav}>{item[1]}</p>
</NavLink>
))}
<a href='/logout' onClick={this.logout}>
<p className='item'>Logout</p>
</a>
</div>
]}
</nav>
);
}
}
export default withRouter(connect(mapUser)(Header));

68
comps/KeyShortcuts.js Normal file
View File

@@ -0,0 +1,68 @@
import { Component } from 'react';
import Router from 'next/router';
import getUrl from '../util/getUrl';
import { getKey } from '../util/keys';
import { doLogout } from '../redux/actions/userAct';
/* - keyboard shortcuts
g then h -> navigate home
g then n -> navigate to new doc
g then s -> navigate to settings
g then l -> logout
e (when on doc page) -> edit doc
/ (when on home page) -> focus search
ctrl/cmd + enter -> submit new doc (handled in CodeMirror component)
*/
const keyToUrl = {
72: '/',
78: '/new',
83: '/settings'
};
const keyToEl = {
69: { sel: '#edit', func: 'click' },
191: { sel: '.search', func: 'focus' }
};
class KeyShortcuts extends Component {
handleDown = e => {
const tag = e.target.tagName;
if(tag === 'INPUT' || tag === 'TEXTAREA') return;
const key = getKey(e);
if(this.prevKey === 71) { // prev key was g
switch (key) {
case 72:
case 78:
case 83: {
const url = keyToUrl[key];
Router.push(url, getUrl(url));
break;
}
case 76: {
setTimeout(doLogout, 1);
break;
}
default: break;
}
}
switch (key) {
case 69:
case 191: {
const { sel, func } = keyToEl[key];
const el = document.querySelector(sel);
if(el) setTimeout(() => el[func](), 1);
break;
}
default: break;
}
this.prevKey = key;
};
componentDidMount() {
window.addEventListener('keydown', this.handleDown);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleDown);
}
render = () => null
}
export default KeyShortcuts;

67
comps/Login.js Normal file
View File

@@ -0,0 +1,67 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { doLogin } from '../redux/actions/userAct';
import Spinner from './Spinner';
import PaddedRow from './PaddedRow';
import mapUser from '../util/mapUser';
class Login extends Component {
state = {
email: '',
pass: ''
};
updVal = e => {
const el = e.target;
const val = el.value;
if(el.getAttribute('type') === 'email') {
return this.setState({ email: val });
}
this.setState({ pass: val });
}
submit = e => {
const { pending } = this.props.user;
let { email, pass } = this.state;
email = email.trim();
pass = pass.trim();
e.preventDefault();
if(pending || email.length === 0 || pass.length == 0) {
return;
}
doLogin({ email, password: pass });
}
render() {
const { pending, error } = this.props.user;
return(
<div className='container content'>
<PaddedRow amount={25} vCenter>
<h4>Please login to continue</h4>
<form noValidate>
<fieldset>
<label htmlFor='email'>Email:</label>
<input type='email' id='email' name='email'
autoFocus placeholder='John@deux.com'
onChange={this.updVal}
/>
<label htmlFor='pass'>Pass:</label>
<input type='password' id='pass' name='password'
placeholder='Super secret password...' onChange={this.updVal}
/>
</fieldset>
<button className={'float-right' + (pending ? ' disabled' : '')}
onClick={this.submit}
>
{pending ? <Spinner /> : 'Submit'}
</button>
{!error ? null : <p>{error}</p>}
</form>
</PaddedRow>
</div>
);
}
}
export default connect(mapUser)(Login);

13
comps/Markdown.js Normal file
View File

@@ -0,0 +1,13 @@
import dynamic from 'next/dynamic';
import freezeSSR from '../util/freezeSSR';
const Markdown = dynamic(import('react-markdown'), freezeSSR('.Markdown'));
const renderers = {
link : props => (<a href={props.href}
target='_blank' rel='noopener noreferrer'>{props.children}</a>)
};
const AddRenderers = ({ className, source }) => (
<Markdown {...{className, source, renderers}} />
);
export default AddRenderers;

150
comps/MngDoc.js Normal file
View File

@@ -0,0 +1,150 @@
import { Component } from 'react';
import Router from 'next/router';
import dynamic from 'next/dynamic';
import Page from '../comps/Page';
import Markdown from '../comps/Markdown';
import { checkDir, checkName } from '../util/checkDirParts';
import updStateFromId from '../util/updStateFromId';
import getUrl from '../util/getUrl';
import getJwt from '../util/getJwt';
const CodeMirrorSkel = () => (
<div className='column'>
<textarea style={{ height: 'calc(300px - 1.2rem)', margin: 0 }} />
</div>
);
const CodeMirror = dynamic(import('../comps/CodeMirror'), {
loading: CodeMirrorSkel, ssr: false
});
const initState = {
name: '',
dir: '',
md: '## New Document!!',
editMode: false,
error: null,
pending: false
};
export default class MngDoc extends Component {
state = initState;
updVal = updStateFromId.bind(this);
updMd = md => this.setState({ md });
submit = async () => {
let { name, md, dir, editMode } = this.state;
let data = {
name: checkName(name),
dir: checkDir(dir),
md
};
const doErr = error => this.setState({ pending: false, error });
const dirErr = 'can only contain A-Z, a-z, 0-9, -, or . and not start or end with .';
if(!data.name) return doErr(
'Document name ' + (data.name === 0 ? 'can not be empty' : dirErr)
);
if(!data.dir && data.dir !== 0) {
return doErr('Directory ' + dirErr);
} else if(data.dir === 0) {
data.dir = '';
}
if(data.md.trim().length === 0) {
return doErr('Content can not be empty');
}
let url = getUrl('docs'),
method = 'POST',
headers = { Authorization: getJwt(),
'Content-Type': 'application/json'
};
if(editMode) {
let numRemoved = 0;
const dataKeys = Object.keys(data);
dataKeys.forEach(k => {
if(data[k] === this.props.doc[k]) {
delete data[k];
numRemoved++;
}
});
if(dataKeys.length === numRemoved) return;
url = getUrl('docs/' + this.props.doc.id);
method = 'PATCH';
}
this.setState({ error: null, pending: true });
const res = await fetch(url, {
headers, method,
body: JSON.stringify(data)
}).catch(doErr);
try {
data = await res.json();
} catch (err) {
data = { message: 'An error occurred submitting doc' };
}
if(res.ok) {
const { id } = data;
return Router.push({
pathname: '/k',
query: { id }
}, getUrl(`k/${id}`));
}
doErr(data.message);
}
static getDerivedStateFromProps(nextProps, prevState) {
const { doc } = nextProps;
if(doc) {
const { name, dir, md } = doc;
return { name, md, dir, editMode: true };
} else if(prevState.id) {
return initState;
}
return null;
}
render() {
const { md, dir, name, error, pending } = this.state;
const rowStyle = { paddingTop: 10 };
return(
<Page>
<div className='row fill' style={rowStyle}>
<div className='column'>
<Markdown className='fill Markdown' source={md} />
</div>
<div className='column'>
<div className='row'>
<div className='column column-60'>
<input type='text' maxLength={250}
placeholder='New document name' id='name'
value={name} onChange={this.updVal}
/>
</div>
<div className='column'>
<input type='text' maxLength={1024}
placeholder='Subdirectory (optional)' id='dir'
value={dir} onChange={this.updVal}
/>
</div>
</div>
<div className='row'>
<CodeMirror value={md}
className='column WrapCodeMirror'
onChange={this.updMd}
onSubmit={this.submit}
options={{
theme: 'monokai',
mode: 'markdown',
lineWrapping: true
}}
/>
</div>
<div className='row' style={{ marginTop: 5 }}>
<div className='column'>
<span>{error}</span>
<button className='float-right'
style={{ marginTop: 5 }}
onClick={pending ? null : this.submit}
>Submit</button>
</div>
</div>
</div>
</div>
</Page>
);
}
}

18
comps/PaddedRow.js Normal file
View File

@@ -0,0 +1,18 @@
const PaddedRow = ({ children, amount, vCenter }) => {
amount = amount || 20;
const PadItem = () => (
<div className={'column column-' + amount + ' no-mob'} />
);
let rowProps = {className: 'row'};
if(vCenter) rowProps = {className: 'row v-center'};
else rowProps = {...rowProps, style: {paddingTop: amount}};
return (
<div {...rowProps}>
<PadItem />
<div className='column'>{ children }</div>
<PadItem />
</div>
);
};
export default PaddedRow;

25
comps/Page.js Normal file
View File

@@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import Header from './Header';
import KeyShortcuts from './KeyShortcuts';
import Footer from './Footer';
import Login from './Login';
import Setup from './Setup';
import mapUser from '../util/mapUser';
const Page = ({ user, children }) => {
return (
<div>
<Header />
<KeyShortcuts />
{user.email
? <div className='container content'>
{ children }
</div>
: user.setup
? <Setup /> : <Login {...{user}} />
}
<Footer />
</div>
);
};
export default connect(mapUser)(Page);

97
comps/Setup.js Normal file
View File

@@ -0,0 +1,97 @@
import React, { Component } from 'react';
import { doLogin } from '../redux/actions/userAct';
import PaddedRow from './PaddedRow';
import Spinner from './Spinner';
import getUrl from '../util/getUrl';
export default class Setup extends Component {
state = {
email: '',
password: '',
confirmPass: '',
pending: false,
error: null
}
updVal = e => {
const el = e.target;
let key = 'email';
if(el.id === 'pass') key = 'password';
else if(el.id === 'pass2') key = 'confirmPass';
const obj = {};
obj[key] = el.value;
this.setState(obj);
}
submit = e => {
e.preventDefault();
let { email, password, confirmPass, pending } = this.state;
if(pending) return;
email = email.trim();
password = password.trim();
confirmPass = confirmPass.trim();
const hasEmpty = [ email, password, confirmPass ]
.some(val => val.length === 0);
if(hasEmpty) return;
if(password.toLowerCase() !== confirmPass.toLowerCase()) {
return this.setState({ error: 'Passwords don\'t match' });
}
this.setState({ pending: true, error: null });
const defaultErr = 'Could not create account';
fetch(getUrl('users'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, admin: true })
})
.then(res => {
if(res.ok) {
return doLogin({ email, password }, null, true);
}
res.json().then(({ message }) => {
const error = message || defaultErr;
this.setState({ pending: false, error });
});
})
.catch(err => {
const error = err.message || defaultErr;
this.setState({ pending: false, error });
});
}
render() {
const { pending, error } = this.state;
return(
<div className='container content'>
<PaddedRow amount={25} vCenter>
<div className='column'>
<h3>Setup account</h3>
<form noValidate>
<fieldset>
<label htmlFor='email'>Email:</label>
<input type='email' autoFocus id='email'
placeholder={'Your email (does\'t have to be actual email)'}
onChange={this.updVal}
/>
<label htmlFor='pass'>Password:</label>
<input type='password' id='pass' maxLength={512}
placeholder='A super secret password'
onChange={this.updVal}
/>
<label htmlFor='pass2'>Confirm Password:</label>
<input type='password' id='pass2' maxLength={512}
placeholder='Confirm your super secret password'
onChange={this.updVal}
/>
<button className='float-right'
onClick={this.submit}
>
{pending ? <Spinner /> : 'Submit'}
</button>
{!error ? null : <p className='danger'>{error}</p>}
</fieldset>
</form>
</div>
</PaddedRow>
</div>
);
}
}

4
comps/Spinner.js Normal file
View File

@@ -0,0 +1,4 @@
const Spinner = (props) => (
<div className='spinner' {...props}></div>
);
export default Spinner;