add changes from v0.3
This commit is contained in:
59
src/client/comps/codemirror.js
Normal file
59
src/client/comps/codemirror.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import config from '../../util/pubConfig'
|
||||
import MonokaiStyles from '../styles/codemirror/monokai'
|
||||
import CodeMirrorStyles from '../styles/codemirror/codemirror'
|
||||
|
||||
let cm
|
||||
let editor
|
||||
let textareaRef
|
||||
|
||||
if (!config.ssr) {
|
||||
require('codemirror/mode/markdown/markdown')
|
||||
cm = require('codemirror')
|
||||
}
|
||||
|
||||
export default function CodeMirror({
|
||||
value,
|
||||
style,
|
||||
className,
|
||||
options = {},
|
||||
onChange = () => {},
|
||||
onSubmit = () => {},
|
||||
}) {
|
||||
const handleChange = (cm, e) => onChange(cm.getValue())
|
||||
const handleSubmit = (cm, e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (cm) {
|
||||
if (!editor || editor.getTextArea() !== textareaRef) {
|
||||
editor = cm.fromTextArea(textareaRef, options)
|
||||
}
|
||||
editor.on('change', handleChange)
|
||||
editor.on('keydown', handleSubmit)
|
||||
}
|
||||
return () => {
|
||||
if (editor) {
|
||||
editor.off('change', handleChange)
|
||||
editor.off('keydown', handleSubmit)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div {...{ className, style }}>
|
||||
<textarea
|
||||
{...{
|
||||
defaultValue: value,
|
||||
ref: el => (textareaRef = el),
|
||||
}}
|
||||
/>
|
||||
|
||||
<MonokaiStyles />
|
||||
<CodeMirrorStyles />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/client/comps/editDoc.js
Normal file
104
src/client/comps/editDoc.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { connect } from 'react-redux'
|
||||
import { updateDoc } from '../util/docHelpers'
|
||||
import isOkDirPart from '../../util/isOkDirPart'
|
||||
|
||||
const Markdown = dynamic(() => import('react-markdown'))
|
||||
const CodeMirror = dynamic(() => import('./codemirror'))
|
||||
const dirError =
|
||||
'contains an invalid character, must only have a-z, 0-9, -, _, and not start or end with a period or space'
|
||||
|
||||
function EditDoc({ cache, query }) {
|
||||
const doc = cache[query.id] || {}
|
||||
const [md, setMd] = useState(
|
||||
doc.md || '### New document\n\nHeres some starting text'
|
||||
)
|
||||
const [dir, setDir] = useState(doc.dir || '')
|
||||
const [name, setName] = useState(doc.name || '')
|
||||
const [error, setError] = useState(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (pending) return
|
||||
let err
|
||||
if (!name.trim()) {
|
||||
err = 'Name is required'
|
||||
} else if (!isOkDirPart(name)) {
|
||||
err = `Name ${dirError}`
|
||||
} else if (dir && dir.split('/').some(dirPart => !isOkDirPart(dirPart))) {
|
||||
err = `Directory ${dirError}`
|
||||
} else if (!md.trim()) {
|
||||
err = 'Contents of markdown can not be empty'
|
||||
}
|
||||
|
||||
if (err) return setError(err)
|
||||
setError(null)
|
||||
setPending(true)
|
||||
updateDoc(query.id, md, name.trim(), dir).catch(err => {
|
||||
setError(err.message)
|
||||
setPending(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container padded editDoc">
|
||||
<div className="row">
|
||||
<div className="column column-50">
|
||||
<Markdown source={md} className="Markdown" />
|
||||
</div>
|
||||
<div className="column column-50">
|
||||
<div className="row">
|
||||
<div className="column column-60">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
maxLength={255}
|
||||
placeholder="Document name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="column column">
|
||||
<input
|
||||
type="text"
|
||||
value={dir}
|
||||
placeholder="Directory (optional)"
|
||||
onChange={e => setDir(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<CodeMirror
|
||||
value={md}
|
||||
onChange={setMd}
|
||||
onSubmit={handleSubmit}
|
||||
style={{ width: '100%' }}
|
||||
className="column wrapCodeMirror"
|
||||
options={{
|
||||
theme: 'monokai',
|
||||
mode: 'markdown',
|
||||
lineWrapping: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="row" style={{ paddingTop: 15 }}>
|
||||
<div className="column">
|
||||
{error && <p className="float-left">{error}</p>}
|
||||
<button className="float-right" onClick={handleSubmit}>
|
||||
{pending ? 'Pending' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
.wrapCodeMirror textarea {
|
||||
margin-bottom: 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(({ cache }) => ({ cache }))(EditDoc)
|
||||
9
src/client/comps/extLink.js
Normal file
9
src/client/comps/extLink.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function extLink({ children, ...props }) {
|
||||
return (
|
||||
<a rel="noopener noreferrer" target="_blank" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
172
src/client/comps/listDocs.js
Normal file
172
src/client/comps/listDocs.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import config from '../../util/pubConfig'
|
||||
import addBase from '../../util/addBase'
|
||||
import loadDocs from '../util/loadDocs'
|
||||
import { connect } from 'react-redux'
|
||||
import Paginate from 'react-paginate'
|
||||
import Router from 'next/router'
|
||||
import Link from 'next/link'
|
||||
|
||||
let searchTimeout
|
||||
let abortController
|
||||
|
||||
const { date, defDocsLimit, searchDelay, ssr } = config
|
||||
const abort = () => {
|
||||
abortController && abortController.abort()
|
||||
}
|
||||
|
||||
function ListDocs({ docs, query }) {
|
||||
const [offline, setOffline] = useState(ssr ? false : !navigator.onLine)
|
||||
const curSort = query.sort || 'updated:-1'
|
||||
const curPage = parseInt(query.page, 10) || 1
|
||||
const pageCount = Math.ceil(docs.total / defDocsLimit)
|
||||
const handleOffline = () => setOffline(true)
|
||||
const handleOnline = () => setOffline(false)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
abortController = loadDocs(query)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
abort()
|
||||
clearTimeout(searchTimeout)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
},
|
||||
[query]
|
||||
)
|
||||
|
||||
const updateUrl = query => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
Router.push({ pathname: '/', query }, { pathname: addBase('/'), query })
|
||||
}, searchDelay)
|
||||
}
|
||||
|
||||
const handleField = e => {
|
||||
const key = e.target.getAttribute('name')
|
||||
updateUrl({
|
||||
...query,
|
||||
page: 1,
|
||||
[key]: e.target.value.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const handlePage = ({ selected }) => {
|
||||
if (curPage - 1 === selected) return
|
||||
updateUrl({
|
||||
...query,
|
||||
page: selected + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container padded">
|
||||
<form action={addBase('/')} method="GET">
|
||||
<div className="row">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
maxLength={300}
|
||||
className="search"
|
||||
onChange={handleField}
|
||||
defaultValue={query.search || ''}
|
||||
placeholder="Search knowledge base..."
|
||||
/>
|
||||
<select
|
||||
name="sort"
|
||||
value={curSort}
|
||||
onChange={handleField}
|
||||
className="column column-25"
|
||||
>
|
||||
<option value="updated:-1">Updated (new to old)</option>
|
||||
<option value="updated:1">Updated (old to new)</option>
|
||||
<option value="created:-1">Created (new to old)</option>
|
||||
<option value="created:1">Created (old to new)</option>
|
||||
<option value="id:1">Path (A to Z)</option>
|
||||
<option value="id:-1">Path (Z to A)</option>
|
||||
</select>
|
||||
</div>
|
||||
<noscript>
|
||||
<button type="submit" className="float-right">
|
||||
Submit
|
||||
</button>
|
||||
</noscript>
|
||||
</form>
|
||||
|
||||
{docs.error && <p>{docs.error} </p>}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Doc{offline && ` (offline mode)`}</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.results.map(doc => {
|
||||
const docUrl = { pathname: '/doc', query: { id: doc.id } }
|
||||
return (
|
||||
<tr key={doc.id}>
|
||||
<td>
|
||||
<Link
|
||||
href={docUrl}
|
||||
as={{ ...docUrl, pathname: addBase('/doc') }}
|
||||
>
|
||||
<a>{doc.id}</a>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{new Date(doc.updated).toLocaleDateString(
|
||||
date.locale,
|
||||
date.options
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!docs.total && <p>No docs found...</p>}
|
||||
|
||||
{docs.total > defDocsLimit && (
|
||||
<Paginate
|
||||
previousLabel="Prev"
|
||||
pageCount={pageCount}
|
||||
marginPagesDisplayed={2}
|
||||
activeClassName="active"
|
||||
forcePage={curPage - 1}
|
||||
onPageChange={handlePage}
|
||||
containerClassName="paginate"
|
||||
hrefBuilder={pg => addBase(`/?page=${pg}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.container {
|
||||
max-width: 750px;
|
||||
margin: 15px auto;
|
||||
}
|
||||
|
||||
.row input {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
td a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
th:nth-of-type(2n),
|
||||
td:nth-of-type(2n) {
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(({ docs }) => ({ docs }))(ListDocs)
|
||||
9
src/client/comps/requireUser.js
Normal file
9
src/client/comps/requireUser.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import Login from '../forms/login'
|
||||
|
||||
function RequireUser({ children, user }) {
|
||||
return user.id ? children : <Login doSetup={user.doSetup} />
|
||||
}
|
||||
|
||||
export default connect(({ user }) => ({ user }))(RequireUser)
|
||||
72
src/client/comps/shortcuts.js
Normal file
72
src/client/comps/shortcuts.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from 'react'
|
||||
import Router from 'next/router'
|
||||
import logout from '../util/logout'
|
||||
import addBase from '../../util/addBase'
|
||||
|
||||
/* - 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' },
|
||||
}
|
||||
const getKey = e => e.which || e.keyCode
|
||||
|
||||
let prevKey
|
||||
|
||||
const handleKeyDown = e => {
|
||||
const tag = e.target.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
const key = getKey(e)
|
||||
if (prevKey === 71) {
|
||||
// prev key was g
|
||||
switch (key) {
|
||||
case 72:
|
||||
case 78:
|
||||
case 83: {
|
||||
const url = keyToUrl[key]
|
||||
Router.push(url, addBase(url))
|
||||
break
|
||||
}
|
||||
case 76: {
|
||||
// logout
|
||||
setTimeout(logout, 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
|
||||
}
|
||||
prevKey = key
|
||||
}
|
||||
|
||||
export default function Shortcuts(props) {
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user