add changes from v0.3

This commit is contained in:
JJ Kasper
2018-11-24 00:23:32 -06:00
parent 73f05ce4a3
commit 111cf2ed35
133 changed files with 10768 additions and 7443 deletions

View File

@@ -1,34 +0,0 @@
// Application hooks that run for every service
const logger = require('./hooks/logger')
module.exports = {
before: {
all: [logger()],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: [],
},
after: {
all: [logger()],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: [],
},
error: {
all: [logger()],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: [],
},
}

View File

@@ -1,133 +0,0 @@
const fs = require('fs')
const path = require('path')
const compress = require('compression')
const cors = require('cors')
const helmet = require('helmet')
const cookieParser = require('cookie-parser')()
const RateLimit = require('express-rate-limit')
const trustIPs = require('./trustIPs')
const logger = require('winston')
const feathers = require('@feathersjs/feathers')
const express = require('@feathersjs/express')
const configuration = require('@feathersjs/configuration')
const hostConfig = require('../config/host.json')
const middleware = require('./middleware')
const services = require('./services')
const appHooks = require('./app.hooks')
const channels = require('./channels')
const authentication = require('./authentication')
const dev = process.env.NODE_ENV !== 'production'
const pathPrefix = require('./util/pathPrefix')
const stripBase = require('./util/stripPrefix')
const getUrl = require('./util/getUrl')
const { parse } = require('url')
const Next = require('next')({ dev, quiet: true })
const nextHandler = Next.getRequestHandler()
const app = express(feathers())
global.app = app
app.run = async port => {
const server = app.listen(port)
await Next.prepare()
if (dev) {
server.on('upgrade', (req, socket) => {
req.url = stripBase(req.url)
nextHandler(req, socket, parse(req.url, true))
})
}
return server
}
// Load app configuration
app.configure(configuration())
// load host and setup settings
Object.keys(hostConfig).forEach(key => app.set(key, hostConfig[key]))
app.set('kbConf', {
pathPrefix,
})
app.set('didSetup', false)
try {
fs.statSync(path.join(__dirname, '..', 'db', '.didSetup'))
app.set('didSetup', true)
} catch (err) {
app.use((req, res, next) => {
req.doSetup = !app.get('didSetup')
next()
})
}
const authLimit = new RateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 5, // 5 attempts then block
delayAfter: 3, // slow down after 3 fails
delayMs: 2 * 1000,
})
app.authLimit = authLimit
app.use(getUrl('auth'), authLimit)
app.patch(getUrl('users/*'), authLimit)
// Enable CORS, security, compression, favicon and body parsing
trustIPs(app)
app.use(cors())
app.use(
helmet({
hidePoweredBy: { setTo: 'hamsters' },
})
)
app.use(getUrl('/'), express.static(path.join(__dirname, '../public')))
if (!dev) app.use(compress())
app.use(express.json()) // use { limit } option to increase max post size
app.use(express.urlencoded({ extended: true }))
app.configure(express.rest()) // Set up Plugins and providers
app.configure(middleware) // middleware/index.js
app.configure(authentication) // Set up authentication
app.configure(services) // Set up our services (see `services/index.js`)
app.configure(channels) // Set up event channels (see channels.js)
Next.setAssetPrefix(pathPrefix)
const checkJWT = async (req, res, next) => {
const result = await req.app.authenticate('jwt', {})(req)
if (result.success) {
req.jwt = req.cookies.jwt
delete result.data.user.password
req.user = result.data.user
}
next()
}
;['/', '/logout', '/new', '/settings'].forEach(route => {
app.get(getUrl(route), cookieParser, checkJWT, (req, res) => {
const { query } = parse(req.url, true)
Next.render(req, res, route, query)
})
})
;['/k', '/edit'].forEach(route => {
app.get(getUrl(route + '/:id'), cookieParser, checkJWT, (req, res) => {
Next.render(req, res, route, { id: req.params.id })
})
})
const notFound = express.notFound()
app.use((req, res, next) => {
let accept = req.get('accept')
if (accept && accept.toLowerCase() === 'application/json')
return notFound(req, res, next)
if (req.url.substr(0, pathPrefix.length) !== pathPrefix)
return Next.render404(req, res)
req.url = stripBase(req.url)
nextHandler(req, res, parse(req.url, true))
})
app.use(express.errorHandler({ logger }))
app.hooks(appHooks)
module.exports = app

View File

@@ -1,42 +0,0 @@
const authentication = require('@feathersjs/authentication')
const jwt = require('@feathersjs/authentication-jwt')
const local = require('@feathersjs/authentication-local')
const getUrl = require('./util/getUrl')
module.exports = function(app) {
const config = app.get('authentication')
config.path = getUrl(config.path)
config.service = getUrl('users')
// Set up authentication with the secret
app.configure(
authentication(
Object.assign({}, config, {
cookie: {
enabled: true,
httpOnly: false,
secure: false,
name: 'jwt',
},
})
)
)
app.configure(jwt())
app.configure(local())
// The `authentication` service is used to create a JWT.
// The before `create` hook registers strategies that can be used
// to create a new valid JWT (e.g. local or oauth2)
app.service(config.path).hooks({
before: {
create: [
authentication.hooks.authenticate(config.strategies),
ctx => {
ctx.app.authLimit.resetKey(ctx.params.ip)
return ctx
},
],
remove: [authentication.hooks.authenticate('jwt')],
},
})
}

View File

@@ -1,64 +0,0 @@
module.exports = function(app) {
if (typeof app.channel !== 'function') {
// If no real-time functionality has been configured just return
return
}
app.on('connection', connection => {
// On a new real-time connection, add it to the anonymous channel
app.channel('anonymous').join(connection)
})
app.on('login', (authResult, { connection }) => {
// connection can be undefined if there is no
// real-time connection, e.g. when logging in via REST
if (connection) {
// Obtain the logged in user from the connection
// const user = connection.user;
// The connection is no longer anonymous, remove it
app.channel('anonymous').leave(connection)
// Add it to the authenticated user channel
app.channel('authenticated').join(connection)
// Channels can be named anything and joined on any condition
// E.g. to send real-time events only to admins use
// if(user.isAdmin) { app.channel('admins').join(connection); }
// If the user has joined e.g. chat rooms
// if(Array.isArray(user.rooms)) user.rooms.forEach(room => app.channel(`rooms/${room.id}`).join(channel));
// Easily organize users by email and userid for things like messaging
// app.channel(`emails/${user.email}`).join(channel);
// app.channel(`userIds/$(user.id}`).join(channel);
}
})
// eslint-disable-next-line no-unused-vars
app.publish((data, hook) => {
// Here you can add event publishers to channels set up in `channels.js`
// To publish only for a specific event use `app.publish(eventname, () => {})`
// eslint-disable-next-line
console.log(
'Publishing all events to all authenticated users. See `channels.js` and https://docs.feathersjs.com/api/channels.html for more information.'
)
// e.g. to publish all service events to all authenticated users use
return app.channel('authenticated')
})
// Here you can also add service specific event publishers
// e..g the publish the `users` service `created` event to the `admins` channel
// app.service('users').publish('created', () => app.channel('admins'));
// With the userid and email organization from above you can easily select involved users
// app.service('messages').publish(() => {
// return [
// app.channel(`userIds/${data.createdBy}`),
// app.channel(`emails/${data.recipientEmail}`)
// ];
// });
}

17
src/client/actionTypes.js Normal file
View File

@@ -0,0 +1,17 @@
import mirror from 'keymirror'
export default mirror({
// user actions
SET_USER: null,
USER_LOGOUT: null,
// docs actions
DOC_DELETED: null,
DOCS_ERROR: null,
DOCS_LOADED: null,
DOCS_PENDING: null,
// cache actions
LOAD_CACHE: null,
CACHE_DOCS: null,
})

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

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

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

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

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

112
src/client/forms/login.js Normal file
View File

@@ -0,0 +1,112 @@
import React, { useState, useEffect } from 'react'
import addBase from '../../util/addBase'
import actionTypes from '../actionTypes'
import { getStore } from '../store'
export default function Login({ doSetup }) {
const [mounted, setMounted] = useState(false)
const [pending, setPending] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const endpoint = addBase(doSetup ? '/register' : '/auth')
useEffect(() => setMounted(true), [])
const submit = e => {
e.preventDefault()
if (pending || !username || !password) return
setPending(true)
setError(null)
fetch(endpoint, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
username,
password,
}),
headers: {
'content-type': 'application/json',
},
})
.then(res => {
if (res.status === 429) {
throw new Error('Too many login attempts')
}
return res.json()
})
.then(({ accessToken, message }) => {
if (message) {
throw new Error(message)
}
localStorage.setItem('jwt', accessToken)
getStore().dispatch({
type: actionTypes.SET_USER,
user: {
...JSON.parse(atob(accessToken.split('.')[1])).user,
jwt: accessToken,
},
})
})
.catch(err => {
setError(err.message)
setPending(false)
})
}
return (
<div className="fill">
<h4>{doSetup ? 'Setup account' : 'Please login to continue'}</h4>
<form action={endpoint} method="post">
<label>
Username:
<input
required
autoFocus
type="text"
name="username"
value={username}
placeholder="Username"
onChange={e => setUsername(e.target.value.trim())}
/>
</label>
<label>
Password:
<input
required
type="password"
name="password"
value={password}
placeholder="Super secret password"
onChange={e => setPassword(e.target.value.trim())}
/>
</label>
{!mounted && <input type="hidden" value="true" name="form" />}
{error && <p className="float-left">{error}</p>}
<button disabled={pending} onClick={submit}>
{pending ? 'Pending...' : 'SUBMIT'}
</button>
</form>
<style jsx>{`
div {
width: 100%;
margin: auto;
padding: 10px;
max-width: 550px;
justify-content: center;
}
input {
margin-top: 5px;
}
button {
float: right;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import React, { Component } from 'react'
import { initializeStore } from '../store'
import checkLogin from '../util/checkLogin'
import config from '../../util/pubConfig'
const __NEXT_REDUX_STORE__ = '__NEXT_REDUX_STORE__'
function getOrCreateStore(initialState) {
// Always make a new store if server, otherwise state is shared between requests
if (config.ssr) {
global.reduxStore = initializeStore(initialState)
return global.reduxStore
}
// Create store if unavailable on the client and set it on the window object
if (!window[__NEXT_REDUX_STORE__]) {
window[__NEXT_REDUX_STORE__] = initializeStore(initialState)
}
return window[__NEXT_REDUX_STORE__]
}
export default App => {
return class AppWithRedux extends Component {
static async getInitialProps(appContext) {
// Get or Create the store with `undefined` as initialState
// This allows you to set a custom default initialState
const reduxStore = getOrCreateStore()
// Provide the store to getInitialProps of pages
appContext.ctx.reduxStore = reduxStore
await checkLogin(appContext.ctx.req)
let appProps = {}
if (typeof App.getInitialProps === 'function') {
appProps = await App.getInitialProps.call(App, appContext)
}
return {
...appProps,
initialReduxState: reduxStore.getState(),
}
}
constructor(props) {
super(props)
this.reduxStore = getOrCreateStore(props.initialReduxState)
}
componentDidMount() {
checkLogin()
}
render() {
return <App {...this.props} reduxStore={this.reduxStore} />
}
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react'
import theme from '../theme'
import ExtLink from '../comps/extLink'
export default function Footer(props) {
return (
<footer>
<p>
<span>Powered by</span>
<ExtLink href="//github.com/ijjk/mykb">MYKB</ExtLink>
</p>
<style jsx>{`
footer {
text-align: center;
background: ${theme.primaryAlt};
}
p {
padding: 10px 10px 15px;
margin-bottom: 0;
}
span {
padding-right: 5px;
}
`}</style>
</footer>
)
}

164
src/client/layout/header.js Normal file
View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import theme from '../theme'
import logout from '../util/logout'
import { connect } from 'react-redux'
import { withRouter } from 'next/router'
import addBase from '../../util/addBase'
const navLinks = [
{ link: '/', label: 'Home' },
{ link: '/new', label: 'New' },
{ link: '/settings', label: 'Settings' },
]
function Header({ user, router }) {
const curPath = addBase(router.pathname)
const [menuOpen, setOpen] = useState(false)
const handleChange = () => setOpen(false)
useEffect(() => {
router.events.on('routeChangeComplete', handleChange)
return () => router.events.off('routeChangeComplete', handleChange)
}, [])
return (
<header>
<h3>
<Link href="/" as={addBase('/')}>
<a>MYKB</a>
</Link>
</h3>
{user.id && (
<>
<label htmlFor="menu">MENU</label>
<input
id="menu"
type="checkbox"
checked={menuOpen}
onChange={e => setOpen(e.target.checked)}
/>
</>
)}
<nav>
{user.id && (
<ul>
{navLinks.map(({ link, label }) => (
<li key={label}>
<Link href={link} as={addBase(link)}>
<a className={addBase(link) === curPath ? 'active' : null}>
{label}
</a>
</Link>
</li>
))}
<li>
<a href={addBase('/logout')} onClick={logout}>
Logout
</a>
</li>
</ul>
)}
</nav>
<style jsx>{`
header {
height: 55px;
display: flex;
padding: 0 10px;
flex-direction: row;
align-items: center;
background: ${theme.primaryAlt};
}
h3 {
margin: 0;
line-height: 1;
}
label {
cursor: pointer;
margin-left: auto;
user-select: none;
color: ${theme.link};
}
input[type='checkbox'] {
display: none;
}
nav {
left: 0;
top: 55px;
height: 0;
width: 100%;
z-index: 10;
position: fixed;
overflow: hidden;
background: ${theme.primaryAlt};
transition: height 200ms ease-in-out;
}
input:checked ~ nav {
height: 180px;
}
ul {
margin: 0;
list-style: none;
}
li {
margin: 0;
}
li a {
width: 100vw;
display: flex;
padding: 10px;
align-items: center;
justify-content: center;
transition: all ease 200ms;
}
li a.active,
li a:hover {
opacity: 1;
background: ${theme.primary};
}
@media (min-width: 420px) {
label {
display: none;
}
nav,
ul {
top: 0;
left: none;
height: 55px;
width: auto;
flex-grow: 1;
position: relative;
display: inline-flex;
flex-direction: row;
justify-content: flex-end;
}
li {
margin: 0;
}
li a {
height: 55px;
width: initial;
padding: 0 15px;
}
}
`}</style>
</header>
)
}
export default withRouter(connect(({ user }) => ({ user }))(Header))

51
src/client/store.js Normal file
View File

@@ -0,0 +1,51 @@
import { combineReducers, compose, createStore } from 'redux'
import config from '../util/pubConfig'
import actionTypes from './actionTypes'
// import stores
import user from './stores/userStore'
import docs from './stores/docsStore'
import cache from './stores/cacheStore'
export function initializeStore(initialState) {
let enhancer = undefined
if (!config.ssr) {
const persistState = require('redux-localstorage')
enhancer = compose(
persistState(['cache', 'user'], {
merge: (initial, persisted) => {
return {
...initial,
cache: {
...persisted.cache,
...initial.cache,
},
user: {
...initial.user,
...persisted.user,
},
}
},
})
)
}
const store = combineReducers({
user,
docs,
cache,
})
const rootStore = (state, action) => {
if (action.type === actionTypes.USER_LOGOUT) {
state = undefined
} else if (action.type === actionTypes.LOAD_CACHE) {
state = action.state
}
return store(state, action)
}
return createStore(rootStore, initialState, enhancer)
}
export function getStore() {
return config.ssr ? global.reduxStore : window['__NEXT_REDUX_STORE__']
}

View File

@@ -0,0 +1,27 @@
import actionTypes from '../actionTypes'
const initialState = {}
export default function cache(state = initialState, action) {
switch (action.type) {
case actionTypes.DOCS_LOADED:
case actionTypes.CACHE_DOCS: {
if (!action.data || !action.data.results) return state
// update cache with new results
action.data.results.forEach(doc => {
state[doc.id] = doc
})
return { ...state }
}
case actionTypes.DOC_DELETED: {
return {
...state,
[action.id]: undefined,
}
}
default:
return state
}
}

View File

@@ -0,0 +1,51 @@
import actionTypes from '../actionTypes'
const initialState = {
hasMore: false,
pending: false,
fetchIdx: 0,
error: null,
total: null,
results: [],
page: 1,
}
export default function docs(state = initialState, action) {
if (
action.type !== actionTypes.DOCS_PENDING &&
action.fetchIdx !== state.fetchIdx
) {
return state
}
switch (action.type) {
case actionTypes.DOCS_PENDING: {
return {
...state,
error: null,
pending: true,
fetchIdx: action.fetchIdx,
}
}
case actionTypes.DOCS_LOADED: {
return {
...state,
...action.data,
pending: false,
}
}
case actionTypes.DOCS_ERROR: {
return {
...state,
pending: false,
error: action.error,
}
}
default: {
return state
}
}
}

View File

@@ -0,0 +1,28 @@
import actionTypes from '../actionTypes'
import config from '../../util/pubConfig'
const initialState = {
id: null,
email: null,
admin: null,
doSetup: false,
verified: false,
}
export default function user(state = initialState, action) {
switch (action.type) {
case actionTypes.SET_USER: {
if (state.doSetup && !action.user.doSetup) {
config.doSetup = false
}
return {
...initialState,
...action.user,
}
}
default: {
return state
}
}
}

View File

@@ -0,0 +1,488 @@
import React from 'react'
export default function codemirror(props) {
return (
<style jsx global>{`
/* BASICS */
.CodeMirror {
/* Set height, width, borders, and global font properties here */
font-family: monospace;
height: 300px;
color: black;
direction: ltr;
}
/* PADDING */
.CodeMirror-lines {
padding: 4px 0; /* Vertical padding around content */
}
.CodeMirror pre {
padding: 0 4px; /* Horizontal padding of content */
}
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
border-right: 1px solid #ddd;
background-color: #f7f7f7;
white-space: nowrap;
}
.CodeMirror-linenumbers {
}
.CodeMirror-linenumber {
padding: 0 3px 0 5px;
min-width: 20px;
text-align: right;
color: #999;
white-space: nowrap;
}
.CodeMirror-guttermarker {
color: black;
}
.CodeMirror-guttermarker-subtle {
color: #999;
}
/* CURSOR */
.CodeMirror-cursor {
border-left: 1px solid black;
border-right: none;
width: 0;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-fat-cursor .CodeMirror-cursor {
width: auto;
border: 0 !important;
background: #7e7;
}
.cm-fat-cursor div.CodeMirror-cursors {
z-index: 1;
}
.cm-fat-cursor-mark {
background-color: rgba(20, 255, 20, 0.5);
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
}
.cm-animate-fat-cursor {
width: auto;
border: 0;
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
background-color: #7e7;
}
@-moz-keyframes blink {
0% {
}
50% {
background-color: transparent;
}
100% {
}
}
@-webkit-keyframes blink {
0% {
}
50% {
background-color: transparent;
}
100% {
}
}
@keyframes blink {
0% {
}
50% {
background-color: transparent;
}
100% {
}
}
.cm-tab {
display: inline-block;
text-decoration: inherit;
}
.CodeMirror-rulers {
position: absolute;
left: 0;
right: 0;
top: -50px;
bottom: -20px;
overflow: hidden;
}
.CodeMirror-ruler {
border-left: 1px solid #ccc;
top: 0;
bottom: 0;
position: absolute;
}
/* DEFAULT THEME */
.cm-s-default .cm-header {
color: blue;
}
.cm-s-default .cm-quote {
color: #090;
}
.cm-negative {
color: #d44;
}
.cm-positive {
color: #292;
}
.cm-header,
.cm-strong {
font-weight: bold;
}
.cm-em {
font-style: italic;
}
.cm-link {
text-decoration: underline;
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-s-default .cm-keyword {
color: #708;
}
.cm-s-default .cm-atom {
color: #219;
}
.cm-s-default .cm-number {
color: #164;
}
.cm-s-default .cm-def {
color: #00f;
}
.cm-s-default .cm-variable,
.cm-s-default .cm-punctuation,
.cm-s-default .cm-property,
.cm-s-default .cm-operator {
}
.cm-s-default .cm-variable-2 {
color: #05a;
}
.cm-s-default .cm-variable-3,
.cm-s-default .cm-type {
color: #085;
}
.cm-s-default .cm-comment {
color: #a50;
}
.cm-s-default .cm-string {
color: #a11;
}
.cm-s-default .cm-string-2 {
color: #f50;
}
.cm-s-default .cm-meta {
color: #555;
}
.cm-s-default .cm-qualifier {
color: #555;
}
.cm-s-default .cm-builtin {
color: #30a;
}
.cm-s-default .cm-bracket {
color: #997;
}
.cm-s-default .cm-tag {
color: #170;
}
.cm-s-default .cm-attribute {
color: #00c;
}
.cm-s-default .cm-hr {
color: #999;
}
.cm-s-default .cm-link {
color: #00c;
}
.cm-s-default .cm-error {
color: #f00;
}
.cm-invalidchar {
color: #f00;
}
.CodeMirror-composing {
border-bottom: 2px solid;
}
/* Default styles for common addons */
div.CodeMirror span.CodeMirror-matchingbracket {
color: #0b0;
}
div.CodeMirror span.CodeMirror-nonmatchingbracket {
color: #a22;
}
.CodeMirror-matchingtag {
background: rgba(255, 150, 0, 0.3);
}
.CodeMirror-activeline-background {
background: #e8f2ff;
}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror {
position: relative;
overflow: hidden;
background: white;
}
.CodeMirror-scroll {
overflow: scroll !important; /* Things will break if this is overridden */
/* 30px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -30px;
margin-right: -30px;
padding-bottom: 30px;
height: 100%;
outline: none; /* Prevent dragging from highlighting the element */
position: relative;
}
.CodeMirror-sizer {
position: relative;
border-right: 30px solid transparent;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actual scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
}
.CodeMirror-vscrollbar {
right: 0;
top: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
bottom: 0;
left: 0;
overflow-y: hidden;
overflow-x: scroll;
}
.CodeMirror-scrollbar-filler {
right: 0;
bottom: 0;
}
.CodeMirror-gutter-filler {
left: 0;
bottom: 0;
}
.CodeMirror-gutters {
position: absolute;
left: 0;
top: 0;
min-height: 100%;
z-index: 3;
}
.CodeMirror-gutter {
white-space: normal;
height: 100%;
display: inline-block;
vertical-align: top;
margin-bottom: -30px;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0;
bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
cursor: default;
z-index: 4;
}
.CodeMirror-gutter-wrapper ::selection {
background-color: transparent;
}
.CodeMirror-gutter-wrapper ::-moz-selection {
background-color: transparent;
}
.CodeMirror-lines {
cursor: text;
min-height: 1px; /* prevents collapsing before first draw */
}
.CodeMirror pre {
/* Reset some styles that the rest of the page might have set */
-moz-border-radius: 0;
-webkit-border-radius: 0;
border-radius: 0;
border-width: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre;
word-wrap: normal;
line-height: inherit;
color: inherit;
z-index: 2;
position: relative;
overflow: visible;
-webkit-tap-highlight-color: transparent;
-webkit-font-variant-ligatures: contextual;
font-variant-ligatures: contextual;
}
.CodeMirror-wrap pre {
word-wrap: break-word;
white-space: pre-wrap;
word-break: normal;
}
.CodeMirror-linebackground {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
padding: 0.1px; /* Force widget margins to stay inside of the container */
}
.CodeMirror-widget {
}
.CodeMirror-rtl pre {
direction: rtl;
}
.CodeMirror-code {
outline: none;
}
/* Force content-box sizing for the elements where we expect it */
.CodeMirror-scroll,
.CodeMirror-sizer,
.CodeMirror-gutter,
.CodeMirror-gutters,
.CodeMirror-linenumber {
-moz-box-sizing: content-box;
box-sizing: content-box;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
}
.CodeMirror-cursor {
position: absolute;
pointer-events: none;
}
.CodeMirror-measure pre {
position: static;
}
div.CodeMirror-cursors {
visibility: hidden;
position: relative;
z-index: 3;
}
div.CodeMirror-dragcursors {
visibility: visible;
}
.CodeMirror-focused div.CodeMirror-cursors {
visibility: visible;
}
.CodeMirror-selected {
background: #d9d9d9;
}
.CodeMirror-focused .CodeMirror-selected {
background: #d7d4f0;
}
.CodeMirror-crosshair {
cursor: crosshair;
}
.CodeMirror-line::selection,
.CodeMirror-line > span::selection,
.CodeMirror-line > span > span::selection {
background: #d7d4f0;
}
.CodeMirror-line::-moz-selection,
.CodeMirror-line > span::-moz-selection,
.CodeMirror-line > span > span::-moz-selection {
background: #d7d4f0;
}
.cm-searching {
background-color: #ffa;
background-color: rgba(255, 255, 0, 0.4);
}
/* Used to force a border model for a node */
.cm-force-border {
padding-right: 0.1px;
}
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
/* See issue #2901 */
.cm-tab-wrap-hack:after {
content: '';
}
/* Help users use markselection to safely style text background */
span.CodeMirror-selectedtext {
background: none;
}
`}</style>
)
}

View File

@@ -0,0 +1,117 @@
import React from 'react'
export default function monokai(props) {
return (
<style jsx global>{`
/* Based on Sublime Text's Monokai theme */
.cm-s-monokai.CodeMirror {
background: #272822;
color: #f8f8f2;
}
.cm-s-monokai div.CodeMirror-selected {
background: #49483e;
}
.cm-s-monokai .CodeMirror-line::selection,
.cm-s-monokai .CodeMirror-line > span::selection,
.cm-s-monokai .CodeMirror-line > span > span::selection {
background: rgba(73, 72, 62, 0.99);
}
.cm-s-monokai .CodeMirror-line::-moz-selection,
.cm-s-monokai .CodeMirror-line > span::-moz-selection,
.cm-s-monokai .CodeMirror-line > span > span::-moz-selection {
background: rgba(73, 72, 62, 0.99);
}
.cm-s-monokai .CodeMirror-gutters {
background: #272822;
border-right: 0px;
}
.cm-s-monokai .CodeMirror-guttermarker {
color: white;
}
.cm-s-monokai .CodeMirror-guttermarker-subtle {
color: #d0d0d0;
}
.cm-s-monokai .CodeMirror-linenumber {
color: #d0d0d0;
}
.cm-s-monokai .CodeMirror-cursor {
border-left: 1px solid #f8f8f0;
}
.cm-s-monokai span.cm-comment {
color: #75715e;
}
.cm-s-monokai span.cm-atom {
color: #ae81ff;
}
.cm-s-monokai span.cm-number {
color: #ae81ff;
}
.cm-s-monokai span.cm-comment.cm-attribute {
color: #97b757;
}
.cm-s-monokai span.cm-comment.cm-def {
color: #bc9262;
}
.cm-s-monokai span.cm-comment.cm-tag {
color: #bc6283;
}
.cm-s-monokai span.cm-comment.cm-type {
color: #5998a6;
}
.cm-s-monokai span.cm-property,
.cm-s-monokai span.cm-attribute {
color: #a6e22e;
}
.cm-s-monokai span.cm-keyword {
color: #f92672;
}
.cm-s-monokai span.cm-builtin {
color: #66d9ef;
}
.cm-s-monokai span.cm-string {
color: #e6db74;
}
.cm-s-monokai span.cm-variable {
color: #f8f8f2;
}
.cm-s-monokai span.cm-variable-2 {
color: #9effff;
}
.cm-s-monokai span.cm-variable-3,
.cm-s-monokai span.cm-type {
color: #66d9ef;
}
.cm-s-monokai span.cm-def {
color: #fd971f;
}
.cm-s-monokai span.cm-bracket {
color: #f8f8f2;
}
.cm-s-monokai span.cm-tag {
color: #f92672;
}
.cm-s-monokai span.cm-header {
color: #ae81ff;
}
.cm-s-monokai span.cm-link {
color: #ae81ff;
}
.cm-s-monokai span.cm-error {
background: #f92672;
color: #f8f8f0;
}
.cm-s-monokai .CodeMirror-activeline-background {
background: #373831;
}
.cm-s-monokai .CodeMirror-matchingbracket {
text-decoration: underline;
color: white !important;
}
`}</style>
)
}

138
src/client/styles/global.js Normal file
View File

@@ -0,0 +1,138 @@
import React from 'react'
import theme from '../theme'
export default function global(props) {
return (
<style jsx global>{`
* {
box-sizing: border-box;
}
body, code, pre {
background: ${theme.primary};
color: ${theme.text};
margin: 0;
}
pre, code {
font-size: 1.5rem;
}
input, textarea, select, button, .button, .cm-s-monokai.CodeMirror {
color: ${theme.text};
border-radius: 0.4rem;
border: none !important;
background-color ${theme.primaryAlt} !important;
}
button[disabled] {
cursor: default;
}
input, textarea {
font-size: 1.6rem;
font-family: ${theme.fontFamily};
font-weight: 300;
resize: none;
}
input[disabled], textarea[disabled] {
opacity: 0.8;
cursor: not-allowed;
}
input::placeholder, textarea::placeholder {
opacity: 0.85;
color: ${theme.text};
}
select {
-webkit-appearance: none;
-moz-appearance: none;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%23d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;
}
select:focus {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%239b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>');
}
a {
color: ${theme.link};
cursor: pointer;
}
a:visited {
color: ${theme.link};
}
a:focus, a:hover {
color: ${theme.linkAct};
}
.Markdown pre {
margin-bottom: 2.5rem;
}
.row {
margin: 0 !important;
width: 100% !important;
}
.fill {
/* 55px: header height, 50px: footer height */
display: flex;
flex-direction: column;
min-height: calc(100vh - 55px - 51px);
}
.main > .padded.container {
padding-top: 20px;
padding-bottom: 20px;
}
.danger {
color: ${theme.danger};
}
.paginate {
list-style: none;
text-align: center;
user-select: none;
margin: 0;
}
.paginate li {
display: inline-block;
}
.paginate li.active a {
border-color: ${theme.link};
}
.paginate a {
outline: 0;
border-radius: 50%;
border: 1px solid;
border-color: transparent;
padding: 3px 8px;
}
@media (max-width: 840px) {
.row .column.column-10,
.row .column.column-20,
.row .column.column-25,
.row .column.column-33,
.row .column.column-40,
.row .column.column-50,
.row .column.column-60,
.row .column.column-67,
.row .column.column-75,
.row .column.column-80,
.row .column.column-90 {
flex: 1 1 auto !important;
max-width: 100% !important;
}
}
`}</style>
)
}

View File

@@ -0,0 +1,539 @@
import React from 'react'
export default function milligram(props) {
return (
<style jsx global>{`
*,
*:after,
*:before {
box-sizing: inherit;
}
html {
box-sizing: border-box;
font-size: 62.5%;
}
body {
color: #606c76;
font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial',
sans-serif;
font-size: 1.6em;
font-weight: 300;
letter-spacing: 0.01em;
line-height: 1.6;
}
blockquote {
border-left: 0.3rem solid #d1d1d1;
margin-left: 0;
margin-right: 0;
padding: 1rem 1.5rem;
}
blockquote *:last-child {
margin-bottom: 0;
}
.button,
button,
input[type='button'],
input[type='reset'],
input[type='submit'] {
background-color: #9b4dca;
border: 0.1rem solid #9b4dca;
border-radius: 0.4rem;
color: #fff;
cursor: pointer;
display: inline-block;
font-size: 1.1rem;
font-weight: 700;
height: 3.8rem;
letter-spacing: 0.1rem;
line-height: 3.8rem;
padding: 0 3rem;
text-align: center;
text-decoration: none;
text-transform: uppercase;
white-space: nowrap;
}
.button:focus,
.button:hover,
button:focus,
button:hover,
input[type='button']:focus,
input[type='button']:hover,
input[type='reset']:focus,
input[type='reset']:hover,
input[type='submit']:focus,
input[type='submit']:hover {
background-color: #606c76;
border-color: #606c76;
color: #fff;
outline: 0;
}
.button[disabled],
button[disabled],
input[type='button'][disabled],
input[type='reset'][disabled],
input[type='submit'][disabled] {
cursor: default;
opacity: 0.5;
}
.button[disabled]:focus,
.button[disabled]:hover,
button[disabled]:focus,
button[disabled]:hover,
input[type='button'][disabled]:focus,
input[type='button'][disabled]:hover,
input[type='reset'][disabled]:focus,
input[type='reset'][disabled]:hover,
input[type='submit'][disabled]:focus,
input[type='submit'][disabled]:hover {
background-color: #9b4dca;
border-color: #9b4dca;
}
.button.button-outline,
button.button-outline,
input[type='button'].button-outline,
input[type='reset'].button-outline,
input[type='submit'].button-outline {
background-color: transparent;
color: #9b4dca;
}
.button.button-outline:focus,
.button.button-outline:hover,
button.button-outline:focus,
button.button-outline:hover,
input[type='button'].button-outline:focus,
input[type='button'].button-outline:hover,
input[type='reset'].button-outline:focus,
input[type='reset'].button-outline:hover,
input[type='submit'].button-outline:focus,
input[type='submit'].button-outline:hover {
background-color: transparent;
border-color: #606c76;
color: #606c76;
}
.button.button-outline[disabled]:focus,
.button.button-outline[disabled]:hover,
button.button-outline[disabled]:focus,
button.button-outline[disabled]:hover,
input[type='button'].button-outline[disabled]:focus,
input[type='button'].button-outline[disabled]:hover,
input[type='reset'].button-outline[disabled]:focus,
input[type='reset'].button-outline[disabled]:hover,
input[type='submit'].button-outline[disabled]:focus,
input[type='submit'].button-outline[disabled]:hover {
border-color: inherit;
color: #9b4dca;
}
.button.button-clear,
button.button-clear,
input[type='button'].button-clear,
input[type='reset'].button-clear,
input[type='submit'].button-clear {
background-color: transparent;
border-color: transparent;
color: #9b4dca;
}
.button.button-clear:focus,
.button.button-clear:hover,
button.button-clear:focus,
button.button-clear:hover,
input[type='button'].button-clear:focus,
input[type='button'].button-clear:hover,
input[type='reset'].button-clear:focus,
input[type='reset'].button-clear:hover,
input[type='submit'].button-clear:focus,
input[type='submit'].button-clear:hover {
background-color: transparent;
border-color: transparent;
color: #606c76;
}
.button.button-clear[disabled]:focus,
.button.button-clear[disabled]:hover,
button.button-clear[disabled]:focus,
button.button-clear[disabled]:hover,
input[type='button'].button-clear[disabled]:focus,
input[type='button'].button-clear[disabled]:hover,
input[type='reset'].button-clear[disabled]:focus,
input[type='reset'].button-clear[disabled]:hover,
input[type='submit'].button-clear[disabled]:focus,
input[type='submit'].button-clear[disabled]:hover {
color: #9b4dca;
}
code {
background: #f4f5f6;
border-radius: 0.4rem;
font-size: 86%;
margin: 0 0.2rem;
padding: 0.2rem 0.5rem;
white-space: nowrap;
}
pre {
background: #f4f5f6;
border-left: 0.3rem solid #9b4dca;
overflow-y: hidden;
}
pre > code {
border-radius: 0;
display: block;
padding: 1rem 1.5rem;
white-space: pre;
}
hr {
border: 0;
border-top: 0.1rem solid #f4f5f6;
margin: 3rem 0;
}
input[type='email'],
input[type='number'],
input[type='password'],
input[type='search'],
input[type='tel'],
input[type='text'],
input[type='url'],
input[type='color'],
input[type='date'],
input[type='month'],
input[type='week'],
input[type='datetime'],
input[type='datetime-local'],
input:not([type]),
textarea,
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-color: transparent;
border: 0.1rem solid #d1d1d1;
border-radius: 0.4rem;
box-shadow: none;
box-sizing: inherit;
height: 3.8rem;
padding: 0.6rem 1rem;
width: 100%;
}
input[type='email']:focus,
input[type='number']:focus,
input[type='password']:focus,
input[type='search']:focus,
input[type='tel']:focus,
input[type='text']:focus,
input[type='url']:focus,
input[type='color']:focus,
input[type='date']:focus,
input[type='month']:focus,
input[type='week']:focus,
input[type='datetime']:focus,
input[type='datetime-local']:focus,
input:not([type]):focus,
textarea:focus,
select:focus {
border-color: #9b4dca;
outline: 0;
}
select {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%23d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>')
center right no-repeat;
padding-right: 3rem;
}
select:focus {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%239b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>');
}
textarea {
min-height: 6.5rem;
}
label,
legend {
display: block;
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
fieldset {
border-width: 0;
padding: 0;
}
input[type='checkbox'],
input[type='radio'] {
display: inline;
}
.label-inline {
display: inline-block;
font-weight: normal;
margin-left: 0.5rem;
}
.container {
margin: 0 auto;
max-width: 112rem;
padding: 0 2rem;
position: relative;
width: 100%;
}
.row {
display: flex;
flex-direction: column;
padding: 0;
width: 100%;
}
.row.row-no-padding {
padding: 0;
}
.row.row-no-padding > .column {
padding: 0;
}
.row.row-wrap {
flex-wrap: wrap;
}
.row.row-top {
align-items: flex-start;
}
.row.row-bottom {
align-items: flex-end;
}
.row.row-center {
align-items: center;
}
.row.row-stretch {
align-items: stretch;
}
.row.row-baseline {
align-items: baseline;
}
.row .column {
display: block;
flex: 1 1 auto;
margin-left: 0;
max-width: 100%;
width: 100%;
}
.row .column.column-offset-10 {
margin-left: 10%;
}
.row .column.column-offset-20 {
margin-left: 20%;
}
.row .column.column-offset-25 {
margin-left: 25%;
}
.row .column.column-offset-33,
.row .column.column-offset-34 {
margin-left: 33.3333%;
}
.row .column.column-offset-50 {
margin-left: 50%;
}
.row .column.column-offset-66,
.row .column.column-offset-67 {
margin-left: 66.6666%;
}
.row .column.column-offset-75 {
margin-left: 75%;
}
.row .column.column-offset-80 {
margin-left: 80%;
}
.row .column.column-offset-90 {
margin-left: 90%;
}
.row .column.column-10 {
flex: 0 0 10%;
max-width: 10%;
}
.row .column.column-20 {
flex: 0 0 20%;
max-width: 20%;
}
.row .column.column-25 {
flex: 0 0 25%;
max-width: 25%;
}
.row .column.column-33,
.row .column.column-34 {
flex: 0 0 33.3333%;
max-width: 33.3333%;
}
.row .column.column-40 {
flex: 0 0 40%;
max-width: 40%;
}
.row .column.column-50 {
flex: 0 0 50%;
max-width: 50%;
}
.row .column.column-60 {
flex: 0 0 60%;
max-width: 60%;
}
.row .column.column-66,
.row .column.column-67 {
flex: 0 0 66.6666%;
max-width: 66.6666%;
}
.row .column.column-75 {
flex: 0 0 75%;
max-width: 75%;
}
.row .column.column-80 {
flex: 0 0 80%;
max-width: 80%;
}
.row .column.column-90 {
flex: 0 0 90%;
max-width: 90%;
}
.row .column .column-top {
align-self: flex-start;
}
.row .column .column-bottom {
align-self: flex-end;
}
.row .column .column-center {
-ms-grid-row-align: center;
align-self: center;
}
@media (min-width: 40rem) {
.row {
flex-direction: row;
margin-left: -1rem;
width: calc(100% + 2rem);
}
.row .column {
margin-bottom: inherit;
padding: 0 1rem;
}
}
a {
color: #9b4dca;
text-decoration: none;
}
a:focus,
a:hover {
color: #606c76;
}
dl,
ol,
ul {
list-style: none;
margin-top: 0;
padding-left: 0;
}
dl dl,
dl ol,
dl ul,
ol dl,
ol ol,
ol ul,
ul dl,
ul ol,
ul ul {
font-size: 90%;
margin: 1.5rem 0 1.5rem 3rem;
}
ol {
list-style: decimal inside;
}
ul {
list-style: circle inside;
}
.button,
button,
dd,
dt,
li {
margin-bottom: 1rem;
}
fieldset,
input,
select,
textarea {
margin-bottom: 1.5rem;
}
blockquote,
dl,
figure,
form,
ol,
p,
pre,
table,
ul {
margin-bottom: 2.5rem;
}
table {
border-spacing: 0;
width: 100%;
}
td,
th {
border-bottom: 0.1rem solid #e1e1e1;
padding: 1.2rem 1.5rem;
text-align: left;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
b,
strong {
font-weight: bold;
}
p {
margin-top: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 300;
letter-spacing: -0.1rem;
margin-bottom: 2rem;
margin-top: 0;
}
h1 {
font-size: 4.6rem;
line-height: 1.2;
}
h2 {
font-size: 3.6rem;
line-height: 1.25;
}
h3 {
font-size: 2.8rem;
line-height: 1.3;
}
h4 {
font-size: 2.2rem;
letter-spacing: -0.08rem;
line-height: 1.35;
}
h5 {
font-size: 1.8rem;
letter-spacing: -0.05rem;
line-height: 1.5;
}
h6 {
font-size: 1.6rem;
letter-spacing: 0;
line-height: 1.4;
}
img {
max-width: 100%;
}
.clearfix:after {
clear: both;
content: ' ';
display: table;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
`}</style>
)
}

304
src/client/styles/roboto.js Normal file
View File

@@ -0,0 +1,304 @@
import React from 'react'
export default function roboto(props) {
return (
<style jsx global>{`
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc3CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF,
U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc-CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc2CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc5CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc1CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc0CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,
U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc6CsTYl4BO.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic3CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF,
U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic-CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic2CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic5CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic1CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic0CsTYl4BOQ3o.woff2)
format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,
U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic6CsTYl4BO.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF,
U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fABc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCBc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fBxc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCxc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,
U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfCRc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF,
U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfABc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfCBc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfBxc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfCxc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfChc4AMP6lbBP.woff2)
format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,
U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'),
url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfBBc4AMP6lQ.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
`}</style>
)
}

View File

@@ -1,9 +1,9 @@
export default {
primary: '#202225',
primary: '#202225',
primaryAlt: '#2c2f33',
danger: '#d44848',
danger: '#d44848',
text: '#dcddde',
link: '#00d1b2',
linkAct: '#009e87',
link: '#00d1b2',
linkAct: '#009e87',
fontFamily: `'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif`,
}
}

View File

@@ -0,0 +1,50 @@
import { getStore } from '../store'
import actionTypes from '../actionTypes'
import addBase from '../../util/addBase'
import config from '../../util/pubConfig'
export default async function checkLogin(req) {
let user = req && req.user
if (user) {
user = {
...user,
jwt: req.cookies.jwt,
}
}
const store = getStore()
if (!user && !config.ssr && !store.getState().user.verified) {
const jwt = localStorage.jwt
if (jwt) {
const jwt = localStorage.jwt
await fetch(addBase('/auth'), {
headers: {
Authorization: `bearer ${jwt}`,
},
method: 'POST',
})
.then(res => {
if (res.ok) {
const payload = JSON.parse(atob(jwt.split('.')[1]))
user = {
...payload.user,
verified: true,
jwt,
}
}
})
.catch(() => {})
}
}
if (!user && config.doSetup) {
user = { doSetup: true }
}
if (user)
store.dispatch({
type: actionTypes.SET_USER,
user,
})
}

View File

@@ -0,0 +1,63 @@
import { getStore } from '../store'
import Router from 'next/router'
import getHeaders from './getHeaders'
import actionTypes from '../actionTypes'
import addBase from '../../util/addBase'
/**
* delete doc
* @param { String } id - id of doc to delete
* @returns { Promise }
*/
export const deleteDoc = (id, confirm = true) => {
if (confirm && !window.confirm('Are you sure you want to delete this doc?')) {
return
}
return fetch(addBase(`/docs?id=${id}`), {
headers: getHeaders(),
method: 'DELETE',
})
.then(() => {
Router.push('/', addBase('/')).then(() => {
getStore().dispatch({
type: actionTypes.DOC_DELETED,
id,
})
})
})
.catch(err => {
alert('Error occurred deleting doc: ', err.message)
})
}
/**
*
* @param { String|undefined } id - id of doc if update or undefined if new
* @param { String } md - the documents markdown
* @param { String } name - name of document
* @param { String } dir - sub-dir of docsDir for document
* @returns { Promise }
*/
export const updateDoc = (id, md, name, dir) => {
const method = id ? 'PATCH' : 'POST'
const query = id ? `?id=${id}` : ''
const data = {}
if (md) data.md = md
if (name) data.name = name
if (typeof dir === 'string') data.dir = dir
if (name && name.slice(-3) !== '.md') data.name += '.md'
return fetch(addBase(`/docs${query}`), {
headers: {
...getHeaders(),
'content-type': 'application/json',
},
method,
body: JSON.stringify(data),
}).then(async res => {
const { id, ...data } = await res.json()
if (!id) throw new Error(data.message || 'error occurred adding doc')
const docUrl = `/doc?id=${id}`
Router.push(docUrl, addBase(docUrl))
})
}

View File

@@ -0,0 +1,15 @@
import { getStore } from '../store'
/**
* gets headers for xhr request
* @returns { Object } the headers
*/
export default function getHeaders() {
const { jwt } = getStore().getState().user
return !jwt
? false
: {
Authorization: `Bearer ${jwt}`,
}
}

View File

@@ -0,0 +1,92 @@
import { format } from 'url'
import { getStore } from '../store'
import getHeaders from './getHeaders'
import fetch from 'isomorphic-unfetch'
import addBase from '../../util/addBase'
import actionTypes from '../actionTypes'
import config from '../../util/pubConfig'
import {
buildSortBy,
limitDocs,
searchDocs,
sortDocs,
} from '../../util/kbHelpers'
/**
* loads docs
* @param { Object } query - docs query object
* @param { Boolean } forCache - whether this is meant specifically for cache
* @returns { AbortController } instance of abort controller if supported
*/
export default function loadDocs(query, forCache) {
const queryStr = format({ query })
const store = getStore()
const url = addBase(`/docs${queryStr}`, true)
const headers = getHeaders()
if (!headers) return
const { docs, cache } = store.getState()
const fetchIdx = docs.fetchIdx + 1
store.dispatch({ type: actionTypes.DOCS_PENDING, fetchIdx })
let controller
let signal
if (!config.ssr && 'AbortController' in window) {
controller = new AbortController()
signal = controller.signal
}
const req = fetch(url, { headers, signal })
.then(async res => {
let data = await res.json()
if (res.status !== 200) throw new Error(data.message)
if (data.id) data = { results: [data] }
store.dispatch({
type: forCache ? actionTypes.CACHE_DOCS : actionTypes.DOCS_LOADED,
fetchIdx,
data: {
...data,
page: query.page || 1,
},
})
})
.catch(err => {
if (err.name === 'AbortError') return
// try cached docs if offline
if (err.message === 'Failed to fetch') {
// handle fetching from cache
let cacheDocs
if (query.search) cacheDocs = searchDocs(cache, query.search)
cacheDocs = sortDocs(
cache,
buildSortBy(query),
cacheDocs && cacheDocs.map(doc => doc.id)
)
store.dispatch({
type: actionTypes.DOCS_LOADED,
fetchIdx,
data: {
...limitDocs(
cacheDocs,
query,
config.defDocsLimit,
config.maxDocsLimit
),
page: query.page || 1,
},
})
} else {
store.dispatch({
type: actionTypes.DOCS_ERROR,
error: err.message,
fetchIdx,
})
}
})
if (config.ssr) return req
// return controller to abort request
return controller
}

17
src/client/util/logout.js Normal file
View File

@@ -0,0 +1,17 @@
import { getStore } from '../store'
import addBase from '../../util/addBase'
import actionTypes from '../actionTypes'
export default function logout(e) {
e && e.preventDefault()
delete localStorage.jwt
fetch(addBase('/logout'), {
method: 'GET',
credentials: 'include',
}).then(() => {
getStore().dispatch({
type: actionTypes.USER_LOGOUT,
})
})
}

View File

@@ -0,0 +1,37 @@
const basePath = new URL(location).searchParams.get('basePath')
const offlinePath = basePath + 'offline'
if (basePath !== '/') {
const curBase =
basePath.slice(-1) === '/'
? basePath.substr(0, basePath.length - 1)
: basePath
for (let item of self.precacheConfig) {
item[0] = curBase + item[0]
}
}
self.addEventListener('install', event => {
var offlineRequest = new Request(offlinePath)
event.waitUntil(
fetch(offlineRequest).then(response => {
return caches.open('offline').then(cache => {
return cache.put(offlineRequest, response)
})
})
)
})
self.addEventListener('fetch', event => {
const { request } = event
const { method, headers } = request
if (method === 'GET' && headers.get('accept').includes('text/html')) {
event.respondWith(
fetch(request).catch(err => {
return caches.open('offline').then(cache => {
return cache.match(offlinePath)
})
})
)
}
})

View File

@@ -0,0 +1,8 @@
import addBase from '../../util/addBase'
import config from '../../util/pubConfig'
;(function() {
if (!config.dev && !config.ssr && 'serviceWorker' in navigator) {
const basePath = encodeURIComponent(addBase('/'))
navigator.serviceWorker.register(addBase(`sw.js?basePath=${basePath}`))
}
})()

View File

@@ -1,63 +0,0 @@
import React, { 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, prevState) {
const { found, id, doc } = nextProps
if (prevState.found !== found && !prevState.didInit) {
return { found, id, doc, didInit: true }
}
return null
}
updateDoc = async id => {
this.setState(await getDoc(id))
}
componentDidMount() {
this.updateDoc(this.props.id)
}
componentDidUpdate(prevProps) {
const { user, found, id } = this.props
if (prevProps.user.email === user.email || found) return
if (!user.email) return
this.updateDoc(id)
}
render() {
return <ComposedComponent {...this.state} />
}
}
return connect(mapUser)(DocComp)
}

View File

@@ -1,65 +0,0 @@
import React, { 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>
)
}
}

View File

@@ -1,26 +0,0 @@
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

View File

@@ -1,29 +0,0 @@
import { css } from 'glamor'
import theme from '../styles/theme'
const style = {
textAlign: 'center',
padding: '10px 10px 15px',
background: theme.primaryAlt,
'& p': {
marginBottom: 0,
},
}
const Footer = () => (
<footer className={css(style)}>
<p>
Powered by{' '}
<a
href="//github.com/ijjk/mykb"
target="_blank"
rel="noopener noreferrer"
>
MYKB
</a>
</p>
</footer>
)
export default Footer

View File

@@ -1,174 +0,0 @@
import React, { Component } from 'react'
import { css } from 'glamor'
import theme from '../styles/theme'
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 style = {
background: theme.primaryAlt,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
height: 55,
'& .navbar-brand': {
marginLeft: '0.75em',
marginRight: 'auto',
'& h3': {
marginBottom: 0,
},
},
'& .navbar-burger': {
width: 32,
display: 'none',
marginRight: 10,
'&.active div': {
'&:nth-child(1)': {
transformOrigin: 'center',
transform: 'translateY(8px) rotate(45deg)',
},
'&:nth-child(2)': {
opacity: 0,
},
'&:nth-child(3)': {
transformOrigin: 'left -6px',
transform: 'translateY(8px) rotate(-45deg)',
},
},
'& div': {
transition: 'all ease-in-out 150ms',
width: '100%',
height: 2,
margin: '5px 0',
borderRadius: 1,
background: theme.text,
},
},
'& .navbar-items': {
display: 'inline-flex',
flexDirection: 'row',
'& .active .item, .item:hover': {
background: theme.primary,
},
'& .item': {
margin: 0,
cursor: 'pointer',
padding: '15px 20px',
},
},
'@media screen and (max-width: 840px)': {
'& .navbar-burger': {
display: 'inline-block',
},
'& .navbar-items': {
display: 'block',
overflow: 'hidden',
position: 'fixed',
top: 55,
left: 0,
zIndex: 5,
background: theme.primaryAlt,
width: '100%',
transform: 'scaleY(0)',
transformOrigin: 'top',
transition: 'all ease-in-out 125ms',
'&.active': {
transform: 'scaleY(1)',
overflow: 'auto',
},
'& .item': {
width: '100%',
padding: '5px 0',
textAlign: 'center',
},
},
},
}
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 ' + css(style)}
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))

View File

@@ -1,71 +0,0 @@
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

View File

@@ -1,76 +0,0 @@
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)

View File

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

View File

@@ -1,184 +0,0 @@
import React, { Component } from 'react'
import Router from 'next/router'
import dynamic from 'next/dynamic'
import getUrl from '../util/getUrl'
import getJwt from '../util/getJwt'
import Page from '../components/Page'
import Markdown from '../components/Markdown'
import updStateFromId from '../util/updStateFromId'
import { checkDir, checkName } from '../util/checkDirParts'
import '../styles/monokai'
import '../styles/codemirror'
const CodeMirrorSkel = () => (
<div className="column">
<textarea style={{ height: 'calc(300px - 1.2rem)', margin: 0 }} />
</div>
)
const CodeMirror = dynamic(
typeof window !== 'undefined' && import('../components/CodeMirror'),
{
loading: CodeMirrorSkel,
ssr: false,
}
)
const initState = {
name: '',
dir: '',
md: '## New Document!!',
editMode: false,
error: null,
pending: false,
}
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 && !prevState.didInit) {
const { name, dir, md } = doc
return { name, md, dir, editMode: true, didInit: true }
} else if (!prevState.didInit && prevState.id) {
return { ...initState, didInit: true }
} else if (!prevState.didInit) {
return { didInit: true }
}
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 column-50">
<Markdown className="fill Markdown" source={md} />
</div>
<div className="column column-50">
<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>
)
}
}
export default MngDoc

View File

@@ -1,16 +0,0 @@
const PaddedRow = ({ children, amount, vCenter }) => {
amount = amount || 20
const PadItem = () => <div className={'column column-' + amount + ' nomob'} />
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

View File

@@ -1,24 +0,0 @@
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 />
{(() => {
if (user.email) {
return <div className="container content">{children}</div>
}
return user.setup ? <Setup /> : <Login {...{ user }} />
})()}
<Footer />
</div>
)
}
export default connect(mapUser)(Page)

View File

@@ -1,105 +0,0 @@
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>
)
}
}

View File

@@ -1,2 +0,0 @@
const Spinner = props => <div className="spinner" {...props} />
export default Spinner

View File

@@ -1,23 +0,0 @@
// A hook that logs service method before, after and error
// See https://github.com/winstonjs/winston for documentation
// about the logger.
const logger = require('winston')
// To see more detailed messages, uncomment the following line
// logger.level = 'debug';
module.exports = function() {
return context => {
// This debugs the service call and a stringified version of the hook context
// You can customize the mssage (and logger) to your needs
// logger.debug(
// `${context.type} app.service('${context.path}').${context.method}()`
// )
// if (typeof context.toJSON === 'function') {
// logger.debug('Hook Context', JSON.stringify(context, null, ' '))
// }
// if (context.error) {
// logger.error(context.error)
// }
}
}

View File

@@ -1,11 +1,60 @@
const logger = require('winston')
const app = require('./app')
const port = app.get('port')
const http = require('http')
const path = require('path')
const Next = require('next')
const chokidar = require('chokidar')
const isDev = process.env.NODE_ENV !== 'production'
const next = Next({ dev: isDev, quiet: true })
app.run(port).then(() => {
logger.info('MYKB listening at http://%s:%d', app.get('host'), port)
})
let server = null
let creatingServer = false
process.on('unhandledRejection', (reason, p) =>
logger.error('Unhandled Rejection at: Promise ', p, reason)
)
// prepare next
next.prepare()
global.next = next
async function createServer() {
if (creatingServer) return
creatingServer = true
const { port } = await require('./server/util/config')
if (server) {
console.log('Restarting server...')
await new Promise(resolve => {
server.destroy(() => resolve())
})
}
try {
server = http.createServer(require('./server/server'))
isDev && require('server-destroy')(server)
server.listen(port)
server.once('listening', () => {
creatingServer = false
console.log(`Listening at http://127.0.0.1:${port}`)
})
} catch (err) {
console.error(err)
creatingServer = false
console.log('waiting for change to restart...')
}
}
if (isDev) {
// watch for server changes and hot reload server
// without having to reload next.js
const configPath = path.resolve('./config')
const utilPath = path.resolve('./src/util')
const serverPath = path.resolve('./src/server')
const watcher = chokidar.watch([serverPath, configPath, utilPath], {})
watcher.on('change', path => {
Object.keys(require.cache).forEach(key => {
if (key.indexOf(serverPath) > -1 || key.indexOf(utilPath) > -1) {
delete require.cache[key]
delete module.constructor._pathCache[key]
}
})
createServer()
})
}
createServer()

View File

@@ -1,11 +0,0 @@
// eslint-disable-next-line no-unused-vars
module.exports = function(app) {
// Add your custom middleware here. Remember, that
// in Express the order matters
// add req.ip to feathers
app.use((req, res, next) => {
req.feathers.ip = req.ip
next()
})
}

View File

@@ -1,14 +0,0 @@
const NeDB = require('nedb')
const path = require('path')
module.exports = function(app) {
const dbPath = app.get('nedb')
const Model = new NeDB({
filename: path.join(dbPath, 'users.db'),
autoload: true,
})
Model.ensureIndex({ fieldName: 'email', unique: true })
return Model
}

View File

@@ -1,82 +0,0 @@
import fetch from 'isomorphic-unfetch'
import store from '../store'
import getUrl from '../../util/getUrl'
// define action types
export const SET_USER = 'SET_USER'
export const LOGIN_PENDING = 'LOGIN_PENDING'
export const LOGIN_FAILED = 'LOGIN_FAILED'
export const LOGOUT = 'LOGOUT'
export const setUser = user => {
store.dispatch({
type: SET_USER,
data: user,
})
} // setUser
export const doLogout = () => {
if (typeof window !== 'undefined') {
window.localStorage.removeItem('jwt')
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/;'
}
store.dispatch({ type: LOGOUT })
} // doLogout
export async function doLogin(creds, jwt, noPend) {
!noPend && store.dispatch({ type: LOGIN_PENDING })
const authReqOpts = { method: 'POST', credentials: 'include' }
const authReqHead = {
headers: jwt
? { Authorization: jwt }
: {
'Content-Type': 'application/json',
},
}
const authReqBody = jwt
? null
: {
body: JSON.stringify({ ...creds, strategy: 'local' }),
}
const authReq = new Request(getUrl('auth'), {
...authReqOpts,
...authReqHead,
...authReqBody,
})
const authRes = await fetch(authReq).catch(err => {
store.dispatch({ type: LOGIN_FAILED, data: err.message })
})
if (!authRes.ok) {
let error
try {
error = await authRes.json()
error = error.message
} catch (err) {
error =
authRes.status === 429
? 'Max login attempts reached'
: 'An error occurred during login'
}
return store.dispatch({
type: LOGIN_FAILED,
data: error,
})
}
const { accessToken } = await authRes.json()
const payload = accessToken.split('.')[1]
const { userId } = JSON.parse(atob(payload))
const userReq = new Request(getUrl(`/users/${userId}`), {
headers: {
Authorization: accessToken,
},
})
const userRes = await fetch(userReq)
if (!userRes.ok) {
return store.dispatch({
type: LOGIN_FAILED,
data: 'failed to get user',
})
}
window.localStorage.setItem('jwt', accessToken)
setUser(await userRes.json())
} // doLogin

View File

@@ -1,46 +0,0 @@
import {
SET_USER,
LOGIN_PENDING,
LOGIN_FAILED,
LOGOUT,
} from '../actions/userAct'
const initState = {
setup: false,
_id: null,
email: null,
admin: null,
pending: false,
error: null,
}
function user(state = initState, action) {
switch (action.type) {
case SET_USER: {
return {
...initState,
...action.data,
}
}
case LOGIN_PENDING: {
return {
...initState,
pending: true,
}
}
case LOGIN_FAILED: {
return {
...state,
pending: false,
error: action.data,
}
}
case LOGOUT: {
return initState
}
default:
return state
}
}
export default user

View File

@@ -1,19 +0,0 @@
import { applyMiddleware, combineReducers, createStore } from 'redux'
import user from './reducers/userRed'
let middleware
if (process.env.NODE_ENV !== 'production') {
const logger = require('redux-logger').default
if (typeof window !== 'undefined') {
middleware = applyMiddleware(logger)
}
}
const reducers = combineReducers({
user,
})
export default (middleware
? createStore(reducers, middleware)
: createStore(reducers))

90
src/server/middleware.js Normal file
View File

@@ -0,0 +1,90 @@
const path = require('path')
const DB = require('./util/db')
const bcrypt = require('bcrypt')
const helmet = require('helmet')
const express = require('express')
const passport = require('passport')
const bodyParser = require('body-parser')
const compression = require('compression')
const cookieParser = require('cookie-parser')
const { Strategy } = require('passport-local')
const jwtExtract = require('./util/jwtExtract')
const { Strategy: JwtStrategy } = require('passport-jwt')
const publicDir = path.join(__dirname, '../../public')
const usersDb = path.join(__dirname, '../../config/users.json')
const users = new DB(usersDb)
passport.use(
new Strategy(function(username, password, cb) {
let user = null
const found = Object.keys(users.data).some(id => {
const curUser = users.data[id]
if (curUser.username === username) {
user = curUser
return true
}
})
if (!found) return cb(null, false)
bcrypt.compare(password, user.password).then(match => {
cb(null, match && user)
})
})
)
passport.serializeUser(function(user, cb) {
cb(null, user.id)
})
passport.deserializeUser(function(id, cb) {
const { password, ...user } = users.data[id] || {}
cb(null, user.id && user)
})
module.exports = function middleware(app) {
const jwtOpts = {
...app.config.jwt,
jwtFromRequest: jwtExtract,
secretOrKey: app.config.secret,
}
passport.use(
new JwtStrategy(jwtOpts, function(jwtPayload, cb) {
const id = jwtPayload.user.id
const { password, ...user } = users.data[id]
return cb(null, user)
})
)
// serve public path
app.use('/', express.static(publicDir))
// add gzip support
app.use(compression())
// add helpful headers
app.use(helmet({ hidePoweredBy: { setTo: 'hamsters' } }))
// set up passport.js
app.use(cookieParser())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(passport.initialize())
app.use((req, res, next) => {
passport.authenticate('jwt', function(err, user, info) {
if (user) req.user = user
next()
})(req, res, next)
})
app.use((req, res, next) => {
if (!Object.keys(users.data).length) {
global.publicConfig.doSetup = true
}
next()
})
app.users = users
app.passport = passport
}

33
src/server/routes/auth.js Normal file
View File

@@ -0,0 +1,33 @@
const rateLimit = require('express-rate-limit')
const handleLogin = require('../util/handleLogin')
const { userError } = require('../util/responses')
/**
* sets up auth endpoint
*/
module.exports = function auth(app) {
const { passport } = app
if (!app.config.dev) {
const authLimiter = rateLimit({
windowMs: 2 * 60 * 1000, // 2 minutes
max: 10, // limit each IP to 10 requests per windowMs
})
app.use('/auth', authLimiter)
}
app.post('/auth', (req, res) => {
// allow checking JWT with Authorization header
if (req.get('authorization')) {
return res
.status(req.user ? 200 : 400)
.json({ status: req.user ? 'ok' : 'error' })
}
passport.authenticate('local', {}, (err, user, info) => {
if (err || !user) {
return userError(res, 'Invalid login')
}
handleLogin(req, res, user, app)
})(req, res)
})
}

168
src/server/routes/docs.js Normal file
View File

@@ -0,0 +1,168 @@
const path = require('path')
const fs = require('fs-extra')
const KB = require('../util/kb')
const Git = require('simple-git/promise')
const isOkDoc = require('../util/isOkDoc')
const tryRmdir = require('../util/tryRmdir')
const requireUser = require('../util/requireUser')
const { aOk, notFound, serverError, userError } = require('../util/responses')
const {
sortDocs,
searchDocs,
limitDocs,
buildSortBy,
} = require('../../util/kbHelpers')
/**
* sets up docs endpoint
*/
module.exports = function docs(app) {
let git
const {
useGit,
docsDir,
cacheSize,
maxDocSize,
maxDocsLimit,
defDocsLimit,
} = app.config
const kb = new KB({
cacheSize,
maxDocSize,
kbPath: docsDir,
})
if (useGit) {
git = Git(docsDir)
kb.loaded.then(() => {
const numDocs = Object.keys(kb.docs).length
// check if git needs to be initialized in docs dir
fs.stat(path.join(docsDir, '.git'), err => {
if (err && err.code === 'ENOENT') {
git.init().then(() => {
git.addConfig('user.name', 'mykb')
git.addConfig('user.email', 'mykb@localhost')
if (numDocs === 0) return
git.add('./*').then(() => git.commit('initial commit'))
})
}
})
})
}
// make sure user is logged in
app.use('/docs*', requireUser)
// handle getting docs
app.get('/docs', (req, res) => {
let docs
const { query } = req
if (query.id) {
const doc = kb.docs[query.id]
if (!doc) return notFound(res)
return aOk(res, doc)
}
if (query.search) {
docs = searchDocs(kb.docs, query.search)
}
const sortBy = buildSortBy(query)
docs = sortDocs(kb.docs, sortBy, docs && docs.map(doc => doc.id))
const { total, hasMore, results } = limitDocs(
docs,
query,
defDocsLimit,
maxDocsLimit
)
aOk(res, { total, results, hasMore })
})
// handle new doc
app.post('/docs', async (req, res) => {
const { name, dir, md } = req.body
if (!isOkDoc({ name, dir, md }, kb, res, true)) return
try {
const docPath = path.join(docsDir, dir || '', name)
await fs.outputFile(docPath, md)
useGit && (await git.add(docPath))
useGit && (await git.commit(`added doc ${docPath.split(docsDir)[1]}`))
} catch (err) {
return serverError(res, err)
}
const added = new Date()
const doc = kb.setDoc(path.join(dir || '', name), added, md, added)
aOk(res, doc)
})
// handle update
app.patch('/docs', async (req, res) => {
const { query, body } = req
const doc = kb.docs[query.id]
const { name, dir, md } = body
if (!doc) return notFound(res)
if (!isOkDoc({ name: name || doc.name, dir, md }, kb, res)) return
let newDir = typeof dir === 'string' ? dir : doc.dir
const oldDir = path.join(docsDir, doc.dir)
const oldPath = path.join(oldDir, doc.name)
const docPath = path.join(docsDir, newDir, name || doc.name)
const oldRelPath = oldPath.split(docsDir + '/')[1]
const curRelPath = docPath.split(docsDir + '/')[1]
const isNewPath = oldPath !== docPath
if (isNewPath && kb.docs[curRelPath]) {
return userError(res, 'item already exists at new path')
}
try {
if (md) {
await fs.outputFile(docPath, md)
isNewPath && (await fs.remove(oldPath))
} else if (isNewPath) {
await fs.move(oldPath, docPath)
}
await tryRmdir(docsDir, oldRelPath)
if (useGit && isNewPath) {
await git.rm(oldPath)
await git.add(docPath)
await git.commit(`renamed doc ${oldRelPath} to ${curRelPath}`)
} else if (useGit) {
await git.add(docPath)
await git.commit(`updated doc ${curRelPath}`)
}
} catch (err) {
return serverError(res, err)
}
const updated = new Date()
const updatedDoc = kb.setDoc(curRelPath, doc.created, md || doc.md, updated)
aOk(res, updatedDoc)
})
// handle delete doc
app.delete('/docs', async (req, res) => {
const { query } = req
const doc = { ...kb.docs[query.id] }
if (!doc) {
return notFound(res)
}
const toRemove = path.join(docsDir, doc.id)
try {
await fs.remove(toRemove)
// try removing directory to clean up if was only file in dir
if (doc.dir) {
tryRmdir(docsDir, doc.dir)
}
useGit && (await git.rm(doc.id))
useGit && (await git.commit(`removed doc ${doc.id}`))
} catch (err) {
return serverError(res, err)
}
delete kb.docs[query.id]
aOk(res)
})
}

View File

@@ -0,0 +1,11 @@
const addBase = require('../../util/addBase')
/**
* sets up logout endpoint
*/
module.exports = function logout(app) {
app.get('/logout', (req, res) => {
res.clearCookie('jwt')
res.redirect(addBase('/'))
})
}

View File

@@ -0,0 +1,38 @@
const bcrypt = require('bcrypt')
const uuid = require('uuid/v4')
const handleLogin = require('../util/handleLogin')
/**
* sets up register endpoint
*/
module.exports = function register(app) {
const { users } = app
app.post('/register', (req, res) => {
if (!global.publicConfig.doSetup) {
return res.status(401).json({ message: 'already set up' })
}
const { username, password } = req.body
const taken = Object.keys(users.data).some(id => {
const user = users.data[id]
return user.username === username
})
if (taken) {
return res.send('username taken')
}
bcrypt.hash(password, 10).then(hash => {
const id = uuid()
const user = {
id,
username,
password: hash,
}
users.setData({
...users.data,
[id]: user,
})
delete global.publicConfig.doSetup
handleLogin(req, res, user, app)
})
})
}

44
src/server/routes/user.js Normal file
View File

@@ -0,0 +1,44 @@
const bcrypt = require('bcrypt')
const rateLimit = require('express-rate-limit')
const { aOk, userError, serverError } = require('../util/responses')
/**
* allows updating user
*/
module.exports = function auth(app) {
const { passport, users } = app
if (!app.config.dev) {
const authLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 10, // limit each IP to 10 requests per windowMs
})
app.use('/user', authLimiter)
}
app.patch('/user', (req, res) => {
passport.authenticate('local', {}, (err, user, info) => {
if (err || !user) {
return userError(res, 'Invalid login')
}
const { id } = user
let { newPassword } = req.body
if (!newPassword) {
return userError(res, 'newPassword is required')
}
newPassword = bcrypt.hash(newPassword, 10).then(hash => {
users
.setData({
...users.data,
[id]: {
...users.data[id],
password: hash,
},
})
.then(() => aOk(res))
.catch(err => serverError(res, err))
})
})(req, res)
})
}

60
src/server/server.js Normal file
View File

@@ -0,0 +1,60 @@
const path = require('path')
const express = require('express')
const config = require('./util/config')
const middleware = require('./middleware')
const trustIPs = require('./util/trustIPs')
const app = express()
const router = express.Router()
const next = global.next
async function main() {
router.config = await config
const {
dev,
ssr,
port,
date,
basePath,
searchDelay,
maxDocsLimit,
defDocsLimit,
trustCloudflare,
} = router.config
// resolve paths to absolute paths
router.config.docsDir = path.resolve(router.config.docsDir)
// config made public to the client
global.publicConfig = {
dev,
ssr,
port,
date,
basePath,
searchDelay,
maxDocsLimit,
defDocsLimit,
}
// set up proxy trusting
trustIPs(app, trustCloudflare)
// set next to use basePath
next.setAssetPrefix(basePath)
// apply middleware
middleware(router)
const routes = ['auth', 'logout', 'register', 'docs', 'user']
// set up routes
routes.forEach(route => require('./routes/' + route)(router))
// set up next handler (must come last)
router.use(next.getRequestHandler())
// prefix all routes with baseRoute
app.use(basePath, router)
}
main()
module.exports = app

31
src/server/util/config.js Normal file
View File

@@ -0,0 +1,31 @@
const DB = require('./db')
const path = require('path')
const isDev = process.env.NODE_ENV !== 'production'
const configPath = file => path.join(__dirname, '../../../config', file)
const defaultPath = configPath('default.json')
const productionPath = configPath('production.json')
const defaultConfig = new DB(defaultPath)
const productionConfig = isDev
? { loading: Promise.resolve(), data: {} }
: new DB(productionPath)
/**
* @returns {Promise} - resolves with config when loaded
*/
function config() {
return defaultConfig.loading
.then(() => productionConfig.loading)
.then(() => {
return {
...defaultConfig.data,
...productionConfig.data,
ssr: true,
dev: isDev,
setData: (isDev ? defaultConfig : productionConfig).setData,
}
})
}
module.exports = config()

24
src/server/util/db.js Normal file
View File

@@ -0,0 +1,24 @@
const fs = require('fs-extra')
// read and write to a json file
module.exports = function DB(file) {
this.data = {}
this.setData = function(data) {
this.data = data
return fs.writeFile(file, JSON.stringify(data, null, 2))
}
this.getData = function() {
return this.data
}
this.loading = fs
.readFile(file)
.then(buf => {
this.data = JSON.parse(buf.toString())
})
.catch(err => {
console.error(err)
})
}

View File

@@ -0,0 +1,29 @@
const jwt = require('jsonwebtoken')
const addBase = require('../../util/addBase')
module.exports = function(req, res, user, app) {
req.login(user, {}, err => {
if (err) {
return res.send(err)
}
const curUser = Object.keys(user).reduce((obj, key) => {
if (key !== 'password') obj[key] = user[key]
return obj
}, {})
const token = jwt.sign({ user: curUser }, app.config.secret, app.config.jwt)
res.cookie('jwt', token, {
expires: new Date(Date.now() + 3600 * 24 * 7 * 1000),
httpOnly: true,
})
// redirect if using form submission instead of XHR
if (req.body.form) {
return res.redirect(addBase('/'))
}
res.json({ accessToken: token })
})
}

View File

@@ -0,0 +1,34 @@
const path = require('path')
const { userError } = require('./responses')
const isOkDirPart = require('../../util/isOkDirPart')
/**
* checks if doc request is ok
* @param { Object } doc - doc object with name, dir, md
* @param { Object } kb - the current kb instance
* @param { Object } res - the express.Response object
* @param { boolean } requireAll - whether to require all fields on doc
*/
module.exports = function isOkDoc({ name, dir, md }, kb, res, requireAll) {
let docPath = name
if (!md || typeof md !== 'string' || md.length === 0) {
if (requireAll) return userError(res, 'md can not be empty')
}
if (name && name.slice(-3) !== '.md') {
return userError(res, 'doc name must end in .md')
} else if (name && !isOkDirPart(name)) {
return userError(res, 'name contains an invalid character')
}
if (dir && typeof dir === 'string') {
if (dir.split('/').some(dirPart => !isOkDirPart(dirPart))) {
return userError(res, 'dir contains an invalid character')
}
docPath = path.join(dir, name)
}
if (requireAll && kb.docs[docPath]) {
return userError(res, 'item already exists')
}
return true
}

View File

@@ -0,0 +1,19 @@
const { ExtractJwt } = require('passport-jwt')
const bearerExtract = ExtractJwt.fromAuthHeaderAsBearerToken()
const ssrRoutes = {
'/': 1,
'/doc': 1,
'/new': 1,
'/edit': 1,
'/settings': 1,
}
module.exports = function jwtExtract(req) {
let token = null
if (ssrRoutes[req.path]) {
token = req.cookies.jwt
} else {
token = bearerExtract(req)
}
return token
}

94
src/server/util/kb.js Normal file
View File

@@ -0,0 +1,94 @@
const path = require('path')
const fs = require('fs-extra')
const chokidar = require('chokidar')
/**
* loads markdown files from docs directory
* and watches for changes
* @param { Object } options - the options object
*/
module.exports = function KB({
kbPath = '',
cacheSize = 10 << 20, // 10 MB
maxDocSize = 100 << 10, // 100 KB
}) {
this.docs = {}
this.cachedSize = 0
/**
* update doc entry (does not update doc file)
* @param { String } relPath - relative path of doc to docsDir
* @param { Date } created - date string of when doc was created
* @param { String } md - the markdown string
* @param { Date } updated - date string of when doc was last updated
* @returns { Object } - the new doc entry
*/
this.setDoc = (relPath, created, md, updated) => {
const id = relPath
let dir = relPath.split('/')
const name = dir.pop()
dir = dir.join('/')
const doc = {
name,
dir,
id,
md,
created,
updated,
}
this.docs[id] = doc
return doc
}
/**
* @param { String } path - the absolute path to doc
* @returns { String } - relative path from docs directory
*/
this.getRelPath = path => {
path = path.split(kbPath).pop()
if (path.substr(0, 1) === '/') path = path.substr(1)
return path
}
/**
* handle doc add or change
* @param { String } path - absolute path to doc
* @param { Object|undefined } stats - the fs.stats object for the doc
*/
this.handleDoc = async (path, stats) => {
const relPath = this.getRelPath(path)
if (!stats) stats = await fs.stat(path)
else {
// stats were set so it's a change event
const changedDoc = this.docs[relPath]
if (changedDoc && changedDoc.md) this.cached -= changedDoc.md.length
}
const { birthtime, size, mtime } = stats
let md = null
if (size < maxDocSize && this.cachedSize + size < cacheSize) {
md = await fs.readFile(path)
md = md.toString()
this.cached += md.length
}
this.setDoc(relPath, birthtime, md, mtime)
}
// set up watching of docs dir and populate initial data
this.watcher = chokidar.watch(path.join(kbPath, '/**/*.md'))
this.loaded = new Promise(resolve => {
this.watcher.on('ready', resolve)
})
this.watcher.on('add', this.handleDoc)
this.watcher.on('change', this.handleDoc)
this.watcher.on('unlink', path => {
// doc removed
const id = this.getRelPath(path)
if (this.docs[id] && this.docs[id].md) {
this.cached -= this.docs[id].md.length
}
delete this.docs[id]
})
}

View File

@@ -0,0 +1,17 @@
/**
* returns forbidden status when user not present
* @param {Object} - express.Request
* @param {Object} - express.Response
* @returns {boolean} - whether if had user or not
*/
module.exports = function requireUser(req, res, next) {
if (!req.user) {
res.status(403).json({
status: 'error',
message: 'You do not have permission to access this',
})
return false
}
if (next) next()
return true
}

View File

@@ -0,0 +1,30 @@
const isDev = process.env.NODE_ENV !== 'production'
// standard JSON responses
module.exports = {
aOk: (res, data) => {
res.status(200).json({ status: 'ok', ...data })
},
notFound: res => {
res.status(404).json({
status: 'error',
message: 'item not found',
})
},
serverError: (res, err) => {
isDev && console.log(new Error(err).stack)
res.status(500).json({
status: 'error',
message: (isDev && err && err.message) || 'server encountered error',
})
},
userError: (res, message) => {
res.status(400).json({
status: 'error',
message,
})
},
}

View File

@@ -1,13 +1,12 @@
/* eslint-disable no-console */
const fs = require('fs')
const path = require('path')
const fetch = require('isomorphic-unfetch')
const ips = require('../config/trustIPs.json')
const ips = require('../../../config/trustIPs.json')
ips.push('loopback')
const cfv4 = 'https://www.cloudflare.com/ips-v4'
const cfv6 = 'https://www.cloudflare.com/ips-v6'
const cfConf = path.join(__dirname, '../config/cfIPs.json')
const cfConf = path.resolve('./config/cfIPs.json')
const refreshInterval = 24 * 60 * 60 * 1000
const getIps = str => {
@@ -32,8 +31,8 @@ const getCfIps = async app => {
app.set('trust proxy', [...ips, ...cfIps])
}
module.exports = app => {
if (!app.get('trustCloudflare')) {
module.exports = (app, cloudflare = false) => {
if (!cloudflare) {
return app.set('trust proxy', ips)
}
fs.readFile(cfConf, async (err, buff) => {

View File

@@ -0,0 +1,21 @@
const path = require('path')
const fs = require('fs-extra')
/**
* tries to remove directory parts if empty
* @param { String } docsDir - the docs directory
* @param { String } relPath - relative path from docs dir to try removing
*/
module.exports = async function tryRmdir(docsDir, relPath) {
try {
const parts = relPath.split('/')
if (parts[parts.length - 1].indexOf('.md') > -1) parts.pop()
for (let i = parts.length; i > 0; i--) {
const part = parts.pop()
const curPath = path.join(docsDir, parts.join('/'), part)
await fs.rmdir(curPath)
}
} catch (err) {
// the dir probably isn't empty so ignore
}
}

View File

@@ -1,235 +0,0 @@
const errors = require('@feathersjs/errors')
const gitP = require('simple-git/promise')
const fs = require('fs-extra')
const path = require('path')
const loadDocs = require('./loadDocs')
const comparableFields = ['name', 'dir']
let git
class Service {
constructor(options) {
this.options = options || {}
this.docsDir = this.options.docsDir
this.$limit = this.options.paginate.default
this.useGit = this.options.useGit
this.maxDocSize = 1024 * 100 // 100 kb (don't try reading if bigger)
this.docs = {}
this.updTimeouts = {}
loadDocs.bind(this)()
if (this.useGit) {
git = gitP(this.docsDir)
fs.stat(path.join(this.docsDir, '.git'), async err => {
if (err && err.code === 'ENOENT') {
git.init().then(() => {
git.addConfig('user.name', 'mykb')
git.addConfig('user.email', 'mykb@localhost')
if (this.numInitDocs === 0) return
git.add('./*').then(() => git.commit('initial commit'))
})
}
})
}
}
async getMd(id) {
const doc = this.docs[id]
if (doc.md) return doc.md
const docPath = path.join(this.docsDir, doc.dir, doc.name)
const { size } = await fs.stat(docPath)
if (size > this.maxDocSize) return 'Document is too big to display...'
const buff = await fs.readFile(docPath)
return buff.toString()
}
async isMatch(id, $search) {
const doc = this.docs[id]
const name = doc.name.toLowerCase()
if (name.indexOf($search) > -1) return true
const dir = doc.dir.toLowerCase()
if (dir.indexOf($search) > -1) return true
const relPath = dir + (dir.length > 0 ? '/' : '') + name
if (relPath.toLowerCase().indexOf($search) > -1) return true
let md = await this.getMd(id)
md = md.toLowerCase()
if (md.indexOf($search) > -1) return true
return false
}
async checkRmDir(dir) {
if (dir === '') return
const parts = dir.split('/')
const n = parts.length
for (let i = 0; i < n; i++) {
const dir = parts.filter((p, pIdx) => pIdx < n - i).join('/')
const docsInDir = await this.find({ query: { dir } })
if (docsInDir.total === 0) {
try {
await fs.rmdir(path.join(this.docsDir, dir))
} catch (err) {
return
}
} else return
}
}
async find(params) {
const { query } = params
let { $limit, $skip, $search, $select, $sort } = query
if ($skip < 0) $skip = 0
$limit = $limit || this.$limit
if ($search) {
$search = $search.toLowerCase().trim()
}
let ids = Object.keys(this.docs)
const data = []
const toComp = comparableFields.filter(f => typeof query[f] !== 'undefined')
if (toComp.length > 0) {
ids = ids.filter(id => {
const doc = this.docs[id]
return !toComp.some(f => doc[f] !== query[f])
})
}
if ($search) {
ids = await Promise.all(
ids.map(async id => ((await this.isMatch(id, $search)) ? id : null))
)
ids = ids.filter(Boolean)
}
if (ids.length === 0) return { total: 0, data }
if ($sort) {
const sortKey = Object.keys($sort).pop()
const ascDesc = $sort[sortKey]
if (this.docs[ids[0]][sortKey] || sortKey === 'dirName') {
ids.sort((a, b) => {
a = this.docs[a]
b = this.docs[b]
let parseVal
if (sortKey === 'dirName') {
parseVal = doc => path.join(doc.dir, doc.name)
} else {
parseVal = doc => doc[sortKey]
}
a = parseVal(a)
b = parseVal(b)
let c = 0
if (a < b) c = -1
else if (a > b) c = 1
return c * ascDesc
})
}
}
for (let i = $skip || 0; i < ids.length && data.length < $limit; i++) {
const id = ids[i]
let doc = $select && !$select.md ? this.docs[id] : this.get(id)
if ($select) {
let _doc = {}
$select.forEach(k => (_doc[k] = doc[k]))
doc = _doc
}
data.push(doc)
}
return { total: ids.length, data }
}
// eslint-disable-next-line no-unused-vars
async get(id, params) {
const doc = this.docs[id]
if (!doc) throw new errors.NotFound(`No record found for id '${id}'`)
if (!doc.md) doc.md = await this.getMd(id)
return this.docs[id]
}
// eslint-disable-next-line no-unused-vars
async create(data, params) {
if (Array.isArray(data)) {
return await Promise.all(data.map(current => this.create(current)))
}
const { name, dir, md } = data
try {
const rPath = path.join(dir, name)
const docPath = path.join(this.docsDir, rPath)
await fs.outputFile(docPath, md)
if (this.useGit) {
git.add(rPath).then(() => git.commit(`added doc ${rPath}`))
}
const ts = new Date().toJSON()
return this.setDoc(path.join(dir, name), ts, md, ts)
} catch (err) {
throw new errors.GeneralError('could not create doc')
}
}
// eslint-disable-next-line no-unused-vars
async update(id, data, params) {
throw new errors.MethodNotAllowed('can not update on docs service')
}
// eslint-disable-next-line no-unused-vars
async patch(id, data, params) {
const doc = this.docs[id]
if (!doc) throw new errors.NotFound(`No record found for id '${id}'`)
let { name, dir, md } = data
let diffDir = Boolean(doc.dir !== dir)
if (!name) name = doc.name
if (typeof dir !== 'string') {
dir = doc.dir
diffDir = false
}
const rPath = path.join(dir, name)
const docPath = path.join(this.docsDir, rPath)
if (name !== doc.name || diffDir) {
const oldRPath = path.join(doc.dir, doc.name)
const oldPath = path.join(this.docsDir, oldRPath)
await fs.ensureDir(path.join(this.docsDir, dir))
await fs.move(oldPath, docPath)
if (this.useGit) {
git
.rm(oldRPath)
.then(() => git.add(rPath))
.then(() => git.commit(`renamed doc ${oldRPath} ${rPath}`))
}
id = this.getId(path.join(dir, name))
this.docs[id] = Object.assign({}, doc, { id, name, dir })
delete this.docs[doc.id]
if (diffDir) this.checkRmDir(doc.dir)
}
if (md) {
await fs.writeFile(docPath, md)
if (this.useGit) {
git.add(rPath).then(() => git.commit(`updated doc ${rPath}`))
}
this.docs[id].md = md
}
return this.docs[id]
}
// eslint-disable-next-line no-unused-vars
async remove(id, params) {
const doc = this.docs[id]
if (!id) throw new errors.NotFound(`No record found for id '${id}'`)
const rPath = path.join(doc.dir, doc.name)
const docPath = path.join(this.docsDir, rPath)
await fs.unlink(docPath)
if (this.useGit) {
git.rm(rPath).then(() => git.commit(`removed doc ${rPath}`))
}
delete this.docs[id]
this.checkRmDir(doc.dir)
return doc
}
}
module.exports = function(options) {
return new Service(options)
}
module.exports.Service = Service

View File

@@ -1,100 +0,0 @@
const { authenticate } = require('@feathersjs/authentication').hooks
const { checkDir, checkName } = require('../../util/checkDirParts')
const { disable, invalid, adminOnly } = require('../hooksUtil')
const getUrl = require('../../util/getUrl')
const nameIsValid = name => {
name = checkName(name)
if (!name) return invalid('name')
if (name.substr(name.length - 3).toLowerCase() !== '.md') {
name += '.md'
}
return name
}
const dirIsValid = dir => {
dir = checkDir(dir)
if (!dir && dir !== 0) return invalid('dir')
else if (dir === 0) return ''
return dir
}
const mdIsValid = md => {
if (typeof md !== 'string' || md.trim().length === 0) {
return invalid('md')
}
return md
}
const pathTaken = async (name, dir, app) => {
const matches = await app
.service(getUrl('docs'))
.find({ query: { name, dir } })
if (matches.total > 0) {
return invalid(null, 'filename is taken')
}
}
module.exports = {
before: {
all: [authenticate('jwt')],
find: [],
get: [],
create: [
async ctx => {
const { app, data } = ctx
let { name, dir, md } = data
const k = {}
k.name = nameIsValid(name)
k.dir = dirIsValid(dir)
k.md = mdIsValid(md)
await pathTaken(k.name, k.dir, app)
ctx.data = k
return ctx
},
],
update: [disable],
patch: [
async ctx => {
const { data, app } = ctx
const { name, dir, md } = data
const k = {}
if (name) k.name = nameIsValid(name)
if (typeof dir === 'string') k.dir = dirIsValid(dir) // allow empty string
if (name || typeof dir === 'string') {
let checkName, checkDir
if (!name || typeof dir !== 'string') {
const doc = await app.service(getUrl('docs')).get(ctx.id)
if (!name) checkName = doc.name
if (typeof dir !== 'string') checkDir = doc.dir
}
await pathTaken(
k.name || checkName,
typeof k.dir === 'string' ? k.dir : checkDir,
app
)
}
if (md) k.md = mdIsValid(md)
if (Object.keys(k).length === 0) invalid(null, 'nothing to update')
ctx.data = k
return ctx
},
],
remove: [adminOnly],
},
after: {
all: [],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: [],
},
error: {
all: [],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: [],
},
}

View File

@@ -1,28 +0,0 @@
// Initializes the `docs` service on path `/docs`
const createService = require('./docs.class.js')
const hooks = require('./docs.hooks')
const getUrl = require('../../util/getUrl')
module.exports = function(app) {
const paginate = app.get('paginate')
const docsDir = app.get('docsDir')
const cacheSize = app.get('cacheSize')
const useGit = app.get('useGit')
const options = {
name: 'docs',
paginate,
docsDir,
cacheSize,
useGit,
}
const url = getUrl('docs')
// Initialize our service with any options it requires
app.use(url, createService(options))
// Get our initialized service so that we can register hooks and filters
const service = app.service(url)
service.hooks(hooks)
}

View File

@@ -1,78 +0,0 @@
const chokidar = require('chokidar')
const fs = require('fs-extra')
const path = require('path')
const glob = require('glob')
const crypto = require('crypto')
async function loadDocs() {
const { docsDir, cacheSize } = this.options
this.numInitDocs = glob.sync(path.join(docsDir, '**/*.md')).length
this.cached = 0
this.loaded = false
const getId = relPath =>
crypto
.createHash('sha1')
.update(relPath)
.digest()
.toString('base64')
.substr(0, 16)
.split('/')
.join('_')
this.getId = getId
const setDoc = (relPath, created, md, updated) => {
const id = getId(relPath)
let dir = relPath.split('/')
const name = dir.pop()
dir = dir.join('/')
const doc = {
id,
name,
dir,
created,
md,
updated,
}
this.docs[id] = doc
return doc
}
this.setDoc = setDoc
const watcher = chokidar.watch(path.join(docsDir, '/**/*.md'), {
persistent: true,
})
const relPath = path => {
path = path.split(docsDir).pop()
if (path.substr(0, 1) === '/') path = path.substr(1)
return path
}
const handleDoc = async (path, stats) => {
const rPath = relPath(path)
if (!stats) stats = await fs.stat(path)
else {
// stats was set so it's a change event
const changedDoc = this.docs[getId(rPath)]
if (changedDoc && changedDoc.md) this.cached -= changedDoc.md.length
}
const { birthtime, size, mtime } = stats
let md = null
if (size < this.maxDocSize && this.cached + size < cacheSize) {
md = await fs.readFile(path)
md = md.toString()
this.cached += md.length
}
setDoc(rPath, birthtime, md, mtime)
}
watcher.on('add', handleDoc) // file added
watcher.on('change', handleDoc) // file changed (rename triggers unlink then add)
watcher.on('unlink', path => {
// file removed
const id = getId(relPath(path))
if (this.docs[id] && this.docs[id].md) {
this.cached -= this.docs[id].md.length
}
delete this.docs[id]
})
}
module.exports = loadDocs

View File

@@ -1,23 +0,0 @@
const { BadRequest, Forbidden } = require('@feathersjs/errors')
const isAdmin = ctx => {
const { params } = ctx
return Boolean(params.user && params.user.admin)
}
module.exports = {
disable: () => {
throw new BadRequest('method not allowed')
},
invalid: (field, msg) => {
throw new BadRequest(msg || `invalid ${field} value`)
},
isAdmin: isAdmin,
adminOnly: ctx => {
if (!isAdmin(ctx)) throw new Forbidden('invalid permission')
return ctx
},
}

View File

@@ -1,6 +0,0 @@
const users = require('./users/users.service.js')
const docs = require('./docs/docs.service.js')
module.exports = function(app) {
app.configure(users)
app.configure(docs)
}

View File

@@ -1,85 +0,0 @@
const { authenticate } = require('@feathersjs/authentication').hooks
const { Forbidden } = require('@feathersjs/errors')
const fs = require('fs')
const path = require('path')
const { disable, invalid, isAdmin, adminOnly } = require('../hooksUtil')
const {
hashPassword,
protect,
} = require('@feathersjs/authentication-local').hooks
const invalidStr = str =>
Boolean(typeof str !== 'string' || str.trim().length === 0)
module.exports = {
before: {
all: [],
find: [authenticate('jwt')],
get: [authenticate('jwt')],
create: [
async ctx => {
const { data, app } = ctx
if (app.get('didSetup') && !isAdmin(ctx)) {
throw new Forbidden('invalid permission')
}
const { email, password, admin } = data
if (invalidStr(email)) invalid('email')
if (invalidStr(password)) invalid('password')
if (typeof admin !== 'boolean') invalid('admin')
ctx.data = { email, password, admin }
return ctx
},
hashPassword(),
],
update: [disable],
patch: [
authenticate('local'),
async ctx => {
const { newPassword } = ctx.data
if (invalidStr(newPassword)) invalid('newPassword')
ctx.data = { password: newPassword }
await hashPassword()(ctx)
ctx.app.authLimit.resetKey(ctx.params.ip)
return ctx
},
],
remove: [authenticate('jwt'), adminOnly],
},
after: {
all: [
// Make sure the password field is never sent to the client
// Always must be the last hook
protect('password'),
],
find: [],
get: [],
create: [
async ctx => {
const { app } = ctx
if (app.get('didSetup')) return ctx
app.set('didSetup', true)
fs.writeFileSync(
// create empty file so we cant stat it
path.join(__dirname, '..', '..', '..', 'db', '.didSetup'),
''
)
return ctx
},
],
update: [],
patch: [],
remove: [],
},
error: {
all: [],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: [],
},
}

View File

@@ -1,25 +0,0 @@
// Initializes the `users` service on path `/users`
const createService = require('feathers-nedb')
const createModel = require('../../models/users.model')
const hooks = require('./users.hooks')
const getUrl = require('../../util/getUrl')
module.exports = function(app) {
const Model = createModel(app)
const paginate = app.get('paginate')
const options = {
name: 'users',
Model,
paginate,
}
const url = getUrl('users')
// Initialize our service with any options it requires
app.use(url, createService(options))
// Get our initialized service so that we can register hooks and filters
const service = app.service(url)
service.hooks(hooks)
}

View File

@@ -1,225 +0,0 @@
import { css } from 'glamor'
css.insert(`/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc3CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc-CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc2CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc5CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc1CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc0CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 300;
src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TjASc6CsTYl4BO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic3CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic-CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic2CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic5CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic1CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic0CsTYl4BOQ3o.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v18/KFOjCnqEu92Fr1Mu51TzBic6CsTYl4BO.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fABc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCBc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fBxc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCxc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfCRc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfABc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfCBc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfBxc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfCxc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfChc4AMP6lbBP.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmWUlfBBc4AMP6lQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}`)

View File

@@ -1,350 +0,0 @@
import { css } from 'glamor'
console.log('codemirror styles!!!')
css.insert(`
/* BASICS */
.CodeMirror {
/* Set height, width, borders, and global font properties here */
font-family: monospace;
height: 300px;
color: black;
direction: ltr;
}
/* PADDING */
.CodeMirror-lines {
padding: 4px 0; /* Vertical padding around content */
}
.CodeMirror pre {
padding: 0 4px; /* Horizontal padding of content */
}
.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
border-right: 1px solid #ddd;
background-color: #f7f7f7;
white-space: nowrap;
}
.CodeMirror-linenumbers {}
.CodeMirror-linenumber {
padding: 0 3px 0 5px;
min-width: 20px;
text-align: right;
color: #999;
white-space: nowrap;
}
.CodeMirror-guttermarker { color: black; }
.CodeMirror-guttermarker-subtle { color: #999; }
/* CURSOR */
.CodeMirror-cursor {
border-left: 1px solid black;
border-right: none;
width: 0;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-fat-cursor .CodeMirror-cursor {
width: auto;
border: 0 !important;
background: #7e7;
}
.cm-fat-cursor div.CodeMirror-cursors {
z-index: 1;
}
.cm-fat-cursor-mark {
background-color: rgba(20, 255, 20, 0.5);
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
}
.cm-animate-fat-cursor {
width: auto;
border: 0;
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
background-color: #7e7;
}
@-moz-keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
@-webkit-keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
@keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
/* Can style cursor different in overwrite (non-insert) mode */
.CodeMirror-overwrite .CodeMirror-cursor {}
.cm-tab { display: inline-block; text-decoration: inherit; }
.CodeMirror-rulers {
position: absolute;
left: 0; right: 0; top: -50px; bottom: -20px;
overflow: hidden;
}
.CodeMirror-ruler {
border-left: 1px solid #ccc;
top: 0; bottom: 0;
position: absolute;
}
/* DEFAULT THEME */
.cm-s-default .cm-header {color: blue;}
.cm-s-default .cm-quote {color: #090;}
.cm-negative {color: #d44;}
.cm-positive {color: #292;}
.cm-header, .cm-strong {font-weight: bold;}
.cm-em {font-style: italic;}
.cm-link {text-decoration: underline;}
.cm-strikethrough {text-decoration: line-through;}
.cm-s-default .cm-keyword {color: #708;}
.cm-s-default .cm-atom {color: #219;}
.cm-s-default .cm-number {color: #164;}
.cm-s-default .cm-def {color: #00f;}
.cm-s-default .cm-variable,
.cm-s-default .cm-punctuation,
.cm-s-default .cm-property,
.cm-s-default .cm-operator {}
.cm-s-default .cm-variable-2 {color: #05a;}
.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;}
.cm-s-default .cm-comment {color: #a50;}
.cm-s-default .cm-string {color: #a11;}
.cm-s-default .cm-string-2 {color: #f50;}
.cm-s-default .cm-meta {color: #555;}
.cm-s-default .cm-qualifier {color: #555;}
.cm-s-default .cm-builtin {color: #30a;}
.cm-s-default .cm-bracket {color: #997;}
.cm-s-default .cm-tag {color: #170;}
.cm-s-default .cm-attribute {color: #00c;}
.cm-s-default .cm-hr {color: #999;}
.cm-s-default .cm-link {color: #00c;}
.cm-s-default .cm-error {color: #f00;}
.cm-invalidchar {color: #f00;}
.CodeMirror-composing { border-bottom: 2px solid; }
/* Default styles for common addons */
div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;}
div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;}
.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); }
.CodeMirror-activeline-background {background: #e8f2ff;}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror {
position: relative;
overflow: hidden;
background: white;
}
.CodeMirror-scroll {
overflow: scroll !important; /* Things will break if this is overridden */
/* 30px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -30px; margin-right: -30px;
padding-bottom: 30px;
height: 100%;
outline: none; /* Prevent dragging from highlighting the element */
position: relative;
}
.CodeMirror-sizer {
position: relative;
border-right: 30px solid transparent;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actual scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
}
.CodeMirror-vscrollbar {
right: 0; top: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
bottom: 0; left: 0;
overflow-y: hidden;
overflow-x: scroll;
}
.CodeMirror-scrollbar-filler {
right: 0; bottom: 0;
}
.CodeMirror-gutter-filler {
left: 0; bottom: 0;
}
.CodeMirror-gutters {
position: absolute; left: 0; top: 0;
min-height: 100%;
z-index: 3;
}
.CodeMirror-gutter {
white-space: normal;
height: 100%;
display: inline-block;
vertical-align: top;
margin-bottom: -30px;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0; bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
cursor: default;
z-index: 4;
}
.CodeMirror-gutter-wrapper ::selection { background-color: transparent }
.CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent }
.CodeMirror-lines {
cursor: text;
min-height: 1px; /* prevents collapsing before first draw */
}
.CodeMirror pre {
/* Reset some styles that the rest of the page might have set */
-moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0;
border-width: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre;
word-wrap: normal;
line-height: inherit;
color: inherit;
z-index: 2;
position: relative;
overflow: visible;
-webkit-tap-highlight-color: transparent;
-webkit-font-variant-ligatures: contextual;
font-variant-ligatures: contextual;
}
.CodeMirror-wrap pre {
word-wrap: break-word;
white-space: pre-wrap;
word-break: normal;
}
.CodeMirror-linebackground {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
padding: 0.1px; /* Force widget margins to stay inside of the container */
}
.CodeMirror-widget {}
.CodeMirror-rtl pre { direction: rtl; }
.CodeMirror-code {
outline: none;
}
/* Force content-box sizing for the elements where we expect it */
.CodeMirror-scroll,
.CodeMirror-sizer,
.CodeMirror-gutter,
.CodeMirror-gutters,
.CodeMirror-linenumber {
-moz-box-sizing: content-box;
box-sizing: content-box;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
}
.CodeMirror-cursor {
position: absolute;
pointer-events: none;
}
.CodeMirror-measure pre { position: static; }
div.CodeMirror-cursors {
visibility: hidden;
position: relative;
z-index: 3;
}
div.CodeMirror-dragcursors {
visibility: visible;
}
.CodeMirror-focused div.CodeMirror-cursors {
visibility: visible;
}
.CodeMirror-selected { background: #d9d9d9; }
.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; }
.CodeMirror-crosshair { cursor: crosshair; }
.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; }
.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; }
.cm-searching {
background-color: #ffa;
background-color: rgba(255, 255, 0, .4);
}
/* Used to force a border model for a node */
.cm-force-border { padding-right: .1px; }
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
/* See issue #2901 */
.cm-tab-wrap-hack:after { content: ''; }
/* Help users use markselection to safely style text background */
span.CodeMirror-selectedtext { background: none; }
`)

View File

@@ -1,209 +0,0 @@
import { css, rehydrate, media, keyframes } from 'glamor'
import theme from './theme'
// rehydrate must be called before any glamor calls
// or else styles will duplicate
if (typeof window !== 'undefined') {
rehydrate(window.__NEXT_DATA__.ids)
}
// must be required after rehydrate or it will duplicate
require('./milligram')
require('./Roboto')
css.global('body, code, pre', {
background: theme.primary,
color: theme.text,
margin: 0,
})
css.global('pre, code', {
fontSize: '1.5rem',
})
css.global('input, textarea, select, button, .button, .cm-s-monokai.CodeMirror', {
color: theme.text,
borderRadius: '.4rem',
border: 'none !important',
backgroundColor: `${theme.primaryAlt} !important`,
})
css.global('button[disabled], button.disabled', {
cursor: 'default',
})
css.global('input, textarea', {
fontSize: '1.6rem',
fontFamily: theme.fontFamily,
fontWeight: 300,
resize: 'none',
})
css.global('input[disabled], textarea[disabled]', {
opacity: 0.8,
cursor: 'not-allowed',
})
css.global('input::placeholder, textarea::placeholder', {
opacity: 0.85,
color: theme.text,
})
css.global('select', {
WebkitAppearance: 'none',
MozAppearance: 'none',
textOverflow: '',
background: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%23d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat`,
})
css.global('select:focus', {
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%239b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>')`,
})
css.global('a', {
color: theme.link,
cursor: 'pointer',
})
css.global('a:visited, a:focus', {
color: theme.link,
})
css.global('a:hover', {
color: theme.linkAct,
})
css.global('.danger', {
color: theme.danger,
})
css.global('.noMargin', {
margin: '0 !important',
})
css.global('.float-right', {
marginLeft: 'auto',
})
css.global('.float-left', {
marginRight: 'auto',
})
css.global('.container', {
display: 'flex',
flexDirection: 'column',
})
css.global('.CodeMirror', {
width: '100%',
})
css.global('.cm-s-monokai span.cm-comment', {
color: '#ccc9ba',
})
css.global('.content', {
minHeight: 'calc(100vh - 55px - 50px)',
padding: 10,
})
css.global('.content p, .content pre', {
wordWrap: 'break-word',
})
css.global('.v-center', {
minHeight: 'calc(100vh - 55px - 50px - 20px)',
flexDirection: 'row',
alignItems: 'center',
})
css.global('.nomob', {
display: 'none !important',
})
css.global('.inline', {
display: 'inline-flex !important',
alignItems: 'center',
})
css.global('.inline select, .inline input', {
width: 'auto',
height: 28,
flexGrow: 1,
marginLeft: 5,
marginBottom: 0,
padding: 6,
border: 'none',
})
css.global('.Markdown pre', {
marginBottom: '2.5rem',
})
const spinKeys = keyframes('spinner', {
from: {
transform: 'rotate(0deg)',
},
to: {
transform: 'rotate(360deg)',
},
})
css.global('.spinner', {
height: 24,
width: 24,
borderRadius: '100%',
border: `2px solid ${theme.text}`,
borderRight: 'none',
borderBottom: 'none',
animation: `${spinKeys} 500ms linear infinite`,
})
css.global('.paginate', {
listStyle: 'none',
textAlign: 'center',
userSelect: 'none',
margin: 0,
})
css.global('.paginate li', {
display: 'inline-block',
})
css.global('.paginate li.active a', {
borderColor: theme.link,
})
css.global('.paginate a', {
outline: 0,
borderRadius: '50%',
border: '1px solid',
borderColor: 'transparent',
padding: '3px 8px',
})
css.insert(`
@media screen and (max-width: 639px) {
.row .column.column-50 {
max-width: 100%;
}
}
@media screen and (min-width: 640px) {
.nomob {
display: block !important;
}
}
`)
media('screen and (max-width: 639px)', {
'.row .column.column-50': {
maxWidth: '100%'
}
})
media('screen and (min-width: 640px)', {
'.nomob': {
display: 'block !important',
}
})

File diff suppressed because one or more lines are too long

View File

@@ -1,44 +0,0 @@
import { css } from 'glamor'
css.insert(`
/* Based on Sublime Text's Monokai theme */
.cm-s-monokai.CodeMirror { background: #272822; color: #f8f8f2; }
.cm-s-monokai div.CodeMirror-selected { background: #49483E; }
.cm-s-monokai .CodeMirror-line::selection, .cm-s-monokai .CodeMirror-line > span::selection, .cm-s-monokai .CodeMirror-line > span > span::selection { background: rgba(73, 72, 62, .99); }
.cm-s-monokai .CodeMirror-line::-moz-selection, .cm-s-monokai .CodeMirror-line > span::-moz-selection, .cm-s-monokai .CodeMirror-line > span > span::-moz-selection { background: rgba(73, 72, 62, .99); }
.cm-s-monokai .CodeMirror-gutters { background: #272822; border-right: 0px; }
.cm-s-monokai .CodeMirror-guttermarker { color: white; }
.cm-s-monokai .CodeMirror-guttermarker-subtle { color: #d0d0d0; }
.cm-s-monokai .CodeMirror-linenumber { color: #d0d0d0; }
.cm-s-monokai .CodeMirror-cursor { border-left: 1px solid #f8f8f0; }
.cm-s-monokai span.cm-comment { color: #75715e; }
.cm-s-monokai span.cm-atom { color: #ae81ff; }
.cm-s-monokai span.cm-number { color: #ae81ff; }
.cm-s-monokai span.cm-comment.cm-attribute { color: #97b757; }
.cm-s-monokai span.cm-comment.cm-def { color: #bc9262; }
.cm-s-monokai span.cm-comment.cm-tag { color: #bc6283; }
.cm-s-monokai span.cm-comment.cm-type { color: #5998a6; }
.cm-s-monokai span.cm-property, .cm-s-monokai span.cm-attribute { color: #a6e22e; }
.cm-s-monokai span.cm-keyword { color: #f92672; }
.cm-s-monokai span.cm-builtin { color: #66d9ef; }
.cm-s-monokai span.cm-string { color: #e6db74; }
.cm-s-monokai span.cm-variable { color: #f8f8f2; }
.cm-s-monokai span.cm-variable-2 { color: #9effff; }
.cm-s-monokai span.cm-variable-3, .cm-s-monokai span.cm-type { color: #66d9ef; }
.cm-s-monokai span.cm-def { color: #fd971f; }
.cm-s-monokai span.cm-bracket { color: #f8f8f2; }
.cm-s-monokai span.cm-tag { color: #f92672; }
.cm-s-monokai span.cm-header { color: #ae81ff; }
.cm-s-monokai span.cm-link { color: #ae81ff; }
.cm-s-monokai span.cm-error { background: #f92672; color: #f8f8f0; }
.cm-s-monokai .CodeMirror-activeline-background { background: #373831; }
.cm-s-monokai .CodeMirror-matchingbracket {
text-decoration: underline;
color: white !important;
}
`)

15
src/util/addBase.js Normal file
View File

@@ -0,0 +1,15 @@
const join = require('url-join')
const config = require('./pubConfig')
/**
* adds basePath to url and also returns absolute url during ssr fetch
* @param { String } path - the path to add base to
* @param { Boolean } isFetch - whether its for a fetch
* @returns { String } the path with base added
*/
module.exports = function addBase(path, isFetch) {
if (path.substr(0, 1) === '/' && path.length > 1) path = path.substr(1)
let url = isFetch && config.ssr ? `http://localhost:${config.port}` : ''
url = url.length ? join(url, config.basePath) : config.basePath
return join(url, path)
}

View File

@@ -1,52 +0,0 @@
const isOkDirPart = str => {
if (str.length > 255 || str.length === 0) return false
const end = str.length - 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (
!(c > 47 && c < 58) && // 0-9
!(c > 64 && c < 91) && // A-Z
!(c > 96 && c < 123) && // a-z
!(c === 95) &&
!(c === 45) && // _ and -
!(
(c === 46 || c === 32) && // period or space if not first or last
i !== 0 &&
i !== end
)
) {
return false
}
}
return true
}
module.exports = {
checkDir: dir => {
if (typeof dir !== 'string') return false
dir = dir.trim()
if (dir.length === 0) return 0
if (dir.indexOf('/') > -1) {
dir = dir.split('/').filter(p => p.length !== 0)
if (dir.length === 1) {
if (!isOkDirPart(dir[0])) false
dir = dir[0]
} else if (dir.length === 0) {
dir = ''
} else if (dir.some(part => !isOkDirPart(part))) {
return false
}
} else if (!isOkDirPart(dir)) {
return false
}
return Array.isArray(dir) ? dir.join('/') : dir
},
checkName: name => {
if (typeof name !== 'string') return false
name = name.trim()
if (name.length === 0) return 0
if (!isOkDirPart(name)) return false
return name
},
}

View File

@@ -1,21 +0,0 @@
const freezeSSR = selector => {
const FrozenSSR = () => {
let __html = ''
let props = {}
if (typeof document !== 'undefined') {
let el = document.querySelector(selector)
if (el) {
__html = el.innerHTML
el.getAttributeNames().forEach(attr => {
const attrKey = attr === 'class' ? 'className' : attr
props[attrKey] = el.getAttribute(attr)
})
}
}
return <div {...props} dangerouslySetInnerHTML={{ __html }} />
}
return { loading: FrozenSSR }
}
export default freezeSSR

View File

@@ -1,39 +0,0 @@
import fetch from 'isomorphic-unfetch'
import parseSort from './parseSort'
import getUrl from './getUrl'
import getJwt from './getJwt'
export const $limit = 12 // number of docs per page
export const select = ['id', 'name', 'updated', 'dir'].map((f, i) => ({
[`$select[${i}]`]: f,
}))
export const getDocs = async (q, jwt) => {
const docsRes = await fetch(getUrl('docs', Boolean(jwt)) + q, {
headers: { Authorization: jwt || getJwt() },
}).catch(({ message }) => ({ ok: false, error: message }))
if (docsRes.ok) {
const res = await docsRes.json()
const total = res.total || 0
const docs = res.data || []
return { docs, total }
}
return { total: 0, docs: [], error: docsRes.message }
}
export const buildQ = q => {
if (!q.$search) delete q.$search
if (!q.$skip) delete q.$skip
else {
q.$skip = (q.$skip - 1) * $limit
}
const $sort = parseSort(q.$sort ? q.$sort : 'updated:-1')
delete q.$sort
select.forEach(sel => (q = { ...q, ...sel }))
q = { $limit, ...q }
let url = Object.keys(q)
.map(k => `${k}=${encodeURIComponent(q[k])}`)
.join('&')
url = `?${url}&${$sort}`
return url
}

View File

@@ -1,6 +0,0 @@
export default req => {
if (req) return req.jwt
if (typeof window !== 'undefined') {
return window.localStorage.getItem('jwt')
}
}

View File

@@ -1,18 +0,0 @@
const url = require('url')
const urljoin = require('url-join')
module.exports = (path, absolute) => {
const { pathPrefix } =
typeof window === 'undefined' ? app.get('kbConf') : window.kbConf
path = urljoin(pathPrefix, path)
if (!absolute) return path
// absolute should only be used during ssr
return url.format({
hostname: app.get('host'),
port: app.get('port'),
pathname: path,
protocol: 'http',
})
}

25
src/util/isOkDirPart.js Normal file
View File

@@ -0,0 +1,25 @@
/**
* checks if dir provided dir is ok
* @param {String} dir - the dir to check
* @returns {boolean}
*/
module.exports = function isOkDirPart(dir) {
if (dir.length > 255 || dir.length === 0) return false
const end = dir.length - 1
let prevChar
for (let i = 0; i < dir.length; i++) {
const c = dir.charCodeAt(i)
const isOk = Boolean(
(c > 47 && c < 58) || // 0-9
(c > 64 && c < 91) || // A-Z
(c > 96 && c < 123) || // a-z
c === 95 || // _
c === 45 || // - // allow period and space if not first or last index
// and not consecutive
((c === 46 || c === 32) && i !== 0 && i !== end && prevChar !== c)
)
prevChar = c
if (!isOk) return false
}
return true
}

141
src/util/kbHelpers.js Normal file
View File

@@ -0,0 +1,141 @@
/**
* @param {Array | undefined} ids - possible array of ids to use
* @param {Object} docs - the docs object
* @returns {Array} - original ids or array of all docs ids
*/
const getIds = (ids, docs) => {
return Array.isArray(ids) ? ids : Object.keys(docs)
}
/**
* builds sort by array to use with sortDocs
* @param { Object } query - the req query object
* @returns { Array } - the sortBy array to pass to sortDocs
*/
const buildSortBy = query => {
const sortBy = []
// build sortBy param from query
Object.keys(query)
.filter(key => key.indexOf('sort') > -1)
.map(queryKey => {
const idx =
queryKey.indexOf(':') < 0
? 0
: parseInt(queryKey.split(':').pop(), 10) || 0
const sortParts = query[queryKey].split(':')
const order =
sortParts.length > 1 ? parseInt(sortParts.pop(), 10) || 1 : 1
const key = sortParts.join('')
sortBy[idx] = { key, order }
})
return sortBy.length ? sortBy : undefined
}
/**
* limit number of docs
* @param { Object } query - the URL query object
* @param { number } defLimit - default limit
* @param { number } maxLimit - max limit
*/
const limitDocs = (docs, query, defLimit, maxLimit) => {
const total = (docs && docs.length) || 0
let page
let limit
let offset
page = parseInt(query.page, 10)
if (isNaN(page)) page = 1
limit = parseInt(query.limit, 10)
if (isNaN(limit)) limit = defLimit
else if (limit > maxLimit) limit = maxLimit
offset = parseInt(query.offset, 10)
if (isNaN(offset) || offset > total) {
offset = page > 1 ? (page - 1) * limit : 0
}
return {
total,
page,
limit,
offset,
hasMore: offset + limit < total,
results: docs.splice(offset, limit),
}
}
/**
* sorts docs by provided keys
* @param {Object} docs - the docs object
* @param {Array} sortBy - array of objects describing how to sort
* @param {Array} ids - array of ids to sort
* @returns {Array} - Array of sorted docs
*/
const sortDocs = (docs, sortBy = [{ key: 'updated', order: -1 }], ids) => {
if (!Array.isArray(sortBy)) return {}
ids = getIds(ids, docs)
const sortedIds = ids.sort((idA, idB) => {
const docA = docs[idA]
const docB = docs[idB]
let sortResult = 0
sortBy.some(sorter => {
let { key, order } = sorter
if (typeof order === 'undefined') order = 1
const valA = docA[key]
const valB = docB[key]
// they're the same so sort by next sorter or return 0
if (valA === valB) return false
if (typeof valA === 'string') {
sortResult = valA.localeCompare(valB)
} else {
sortResult = valA < valB ? -1 : 1
}
// if ASC (1) leave alone if DESC (-1) flip result
sortResult *= order
return true
})
return sortResult
})
return sortedIds.reduce((sortedDocs, curId) => {
sortedDocs.push({ ...docs[curId] })
return sortedDocs
}, [])
}
/**
* searches through docs for string
* @param {Object} docs - the docs object
* @param {String} searchStr - the string to search for
* @param {Array} ids - Array ids to search through
* @returns {Array} - array of docs matching search
*/
const searchDocs = (docs, searchStr = '', ids, caseSensitive = false) => {
ids = getIds(ids, docs)
return ids
.filter(id => {
let { md } = docs[id]
md = md || ''
if (!caseSensitive) {
md = md.toLowerCase()
id = id.toLowerCase()
searchStr = searchStr.toLowerCase()
}
return md.indexOf(searchStr) > -1 || id.indexOf(searchStr) > -1
})
.reduce((filteredDocs, curId) => {
filteredDocs.push({ ...docs[curId] })
return filteredDocs
}, [])
}
module.exports = {
sortDocs,
limitDocs,
searchDocs,
buildSortBy,
}

View File

@@ -1,4 +0,0 @@
module.exports = {
getKey: e => e.which || e.keyCode,
isCtrlKey: key => key === 91 || key === 93 || key === 17,
}

View File

@@ -1 +0,0 @@
export default ({ user }) => ({ user })

View File

@@ -1,19 +0,0 @@
export default sort => {
let key, ascDesc
switch (typeof sort) {
case 'object': {
key = Object.keys(sort).pop()
ascDesc = sort[key]
break
}
case 'string': {
const parts = sort.split(':')
key = parts[0]
ascDesc = parts[1]
break
}
default:
break
}
return `$sort[${key}]=${ascDesc}`
}

View File

@@ -1,8 +0,0 @@
// make sure basePath doesn't end with /
let { pathPrefix } = require('../../config/host.json')
const urlChars = pathPrefix.split('')
if (pathPrefix.length > 1 && urlChars.pop() === '/') {
pathPrefix = urlChars.join('')
}
module.exports = pathPrefix

4
src/util/pubConfig.js Normal file
View File

@@ -0,0 +1,4 @@
const config =
typeof window !== 'undefined' ? window.publicConfig : global.publicConfig
module.exports = config

View File

@@ -1,8 +0,0 @@
const pathPrefix = require('./pathPrefix')
module.exports = url => {
if (pathPrefix !== '/') {
url = url.split(pathPrefix).join('')
}
return url
}

View File

@@ -1,4 +0,0 @@
export default function updateStateFromId(e) {
const el = e.target
this.setState({ [el.id]: el.value })
}