add changes from v0.3
This commit is contained in:
@@ -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: [],
|
||||
},
|
||||
}
|
||||
133
src/app.js
133
src/app.js
@@ -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
|
||||
@@ -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')],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
17
src/client/actionTypes.js
Normal 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,
|
||||
})
|
||||
59
src/client/comps/codemirror.js
Normal file
59
src/client/comps/codemirror.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import config from '../../util/pubConfig'
|
||||
import MonokaiStyles from '../styles/codemirror/monokai'
|
||||
import CodeMirrorStyles from '../styles/codemirror/codemirror'
|
||||
|
||||
let cm
|
||||
let editor
|
||||
let textareaRef
|
||||
|
||||
if (!config.ssr) {
|
||||
require('codemirror/mode/markdown/markdown')
|
||||
cm = require('codemirror')
|
||||
}
|
||||
|
||||
export default function CodeMirror({
|
||||
value,
|
||||
style,
|
||||
className,
|
||||
options = {},
|
||||
onChange = () => {},
|
||||
onSubmit = () => {},
|
||||
}) {
|
||||
const handleChange = (cm, e) => onChange(cm.getValue())
|
||||
const handleSubmit = (cm, e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (cm) {
|
||||
if (!editor || editor.getTextArea() !== textareaRef) {
|
||||
editor = cm.fromTextArea(textareaRef, options)
|
||||
}
|
||||
editor.on('change', handleChange)
|
||||
editor.on('keydown', handleSubmit)
|
||||
}
|
||||
return () => {
|
||||
if (editor) {
|
||||
editor.off('change', handleChange)
|
||||
editor.off('keydown', handleSubmit)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div {...{ className, style }}>
|
||||
<textarea
|
||||
{...{
|
||||
defaultValue: value,
|
||||
ref: el => (textareaRef = el),
|
||||
}}
|
||||
/>
|
||||
|
||||
<MonokaiStyles />
|
||||
<CodeMirrorStyles />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/client/comps/editDoc.js
Normal file
104
src/client/comps/editDoc.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { connect } from 'react-redux'
|
||||
import { updateDoc } from '../util/docHelpers'
|
||||
import isOkDirPart from '../../util/isOkDirPart'
|
||||
|
||||
const Markdown = dynamic(() => import('react-markdown'))
|
||||
const CodeMirror = dynamic(() => import('./codemirror'))
|
||||
const dirError =
|
||||
'contains an invalid character, must only have a-z, 0-9, -, _, and not start or end with a period or space'
|
||||
|
||||
function EditDoc({ cache, query }) {
|
||||
const doc = cache[query.id] || {}
|
||||
const [md, setMd] = useState(
|
||||
doc.md || '### New document\n\nHeres some starting text'
|
||||
)
|
||||
const [dir, setDir] = useState(doc.dir || '')
|
||||
const [name, setName] = useState(doc.name || '')
|
||||
const [error, setError] = useState(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (pending) return
|
||||
let err
|
||||
if (!name.trim()) {
|
||||
err = 'Name is required'
|
||||
} else if (!isOkDirPart(name)) {
|
||||
err = `Name ${dirError}`
|
||||
} else if (dir && dir.split('/').some(dirPart => !isOkDirPart(dirPart))) {
|
||||
err = `Directory ${dirError}`
|
||||
} else if (!md.trim()) {
|
||||
err = 'Contents of markdown can not be empty'
|
||||
}
|
||||
|
||||
if (err) return setError(err)
|
||||
setError(null)
|
||||
setPending(true)
|
||||
updateDoc(query.id, md, name.trim(), dir).catch(err => {
|
||||
setError(err.message)
|
||||
setPending(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container padded editDoc">
|
||||
<div className="row">
|
||||
<div className="column column-50">
|
||||
<Markdown source={md} className="Markdown" />
|
||||
</div>
|
||||
<div className="column column-50">
|
||||
<div className="row">
|
||||
<div className="column column-60">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
maxLength={255}
|
||||
placeholder="Document name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="column column">
|
||||
<input
|
||||
type="text"
|
||||
value={dir}
|
||||
placeholder="Directory (optional)"
|
||||
onChange={e => setDir(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<CodeMirror
|
||||
value={md}
|
||||
onChange={setMd}
|
||||
onSubmit={handleSubmit}
|
||||
style={{ width: '100%' }}
|
||||
className="column wrapCodeMirror"
|
||||
options={{
|
||||
theme: 'monokai',
|
||||
mode: 'markdown',
|
||||
lineWrapping: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="row" style={{ paddingTop: 15 }}>
|
||||
<div className="column">
|
||||
{error && <p className="float-left">{error}</p>}
|
||||
<button className="float-right" onClick={handleSubmit}>
|
||||
{pending ? 'Pending' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
.wrapCodeMirror textarea {
|
||||
margin-bottom: 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(({ cache }) => ({ cache }))(EditDoc)
|
||||
9
src/client/comps/extLink.js
Normal file
9
src/client/comps/extLink.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function extLink({ children, ...props }) {
|
||||
return (
|
||||
<a rel="noopener noreferrer" target="_blank" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
172
src/client/comps/listDocs.js
Normal file
172
src/client/comps/listDocs.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import config from '../../util/pubConfig'
|
||||
import addBase from '../../util/addBase'
|
||||
import loadDocs from '../util/loadDocs'
|
||||
import { connect } from 'react-redux'
|
||||
import Paginate from 'react-paginate'
|
||||
import Router from 'next/router'
|
||||
import Link from 'next/link'
|
||||
|
||||
let searchTimeout
|
||||
let abortController
|
||||
|
||||
const { date, defDocsLimit, searchDelay, ssr } = config
|
||||
const abort = () => {
|
||||
abortController && abortController.abort()
|
||||
}
|
||||
|
||||
function ListDocs({ docs, query }) {
|
||||
const [offline, setOffline] = useState(ssr ? false : !navigator.onLine)
|
||||
const curSort = query.sort || 'updated:-1'
|
||||
const curPage = parseInt(query.page, 10) || 1
|
||||
const pageCount = Math.ceil(docs.total / defDocsLimit)
|
||||
const handleOffline = () => setOffline(true)
|
||||
const handleOnline = () => setOffline(false)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
abortController = loadDocs(query)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
abort()
|
||||
clearTimeout(searchTimeout)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
},
|
||||
[query]
|
||||
)
|
||||
|
||||
const updateUrl = query => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
Router.push({ pathname: '/', query }, { pathname: addBase('/'), query })
|
||||
}, searchDelay)
|
||||
}
|
||||
|
||||
const handleField = e => {
|
||||
const key = e.target.getAttribute('name')
|
||||
updateUrl({
|
||||
...query,
|
||||
page: 1,
|
||||
[key]: e.target.value.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const handlePage = ({ selected }) => {
|
||||
if (curPage - 1 === selected) return
|
||||
updateUrl({
|
||||
...query,
|
||||
page: selected + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container padded">
|
||||
<form action={addBase('/')} method="GET">
|
||||
<div className="row">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
maxLength={300}
|
||||
className="search"
|
||||
onChange={handleField}
|
||||
defaultValue={query.search || ''}
|
||||
placeholder="Search knowledge base..."
|
||||
/>
|
||||
<select
|
||||
name="sort"
|
||||
value={curSort}
|
||||
onChange={handleField}
|
||||
className="column column-25"
|
||||
>
|
||||
<option value="updated:-1">Updated (new to old)</option>
|
||||
<option value="updated:1">Updated (old to new)</option>
|
||||
<option value="created:-1">Created (new to old)</option>
|
||||
<option value="created:1">Created (old to new)</option>
|
||||
<option value="id:1">Path (A to Z)</option>
|
||||
<option value="id:-1">Path (Z to A)</option>
|
||||
</select>
|
||||
</div>
|
||||
<noscript>
|
||||
<button type="submit" className="float-right">
|
||||
Submit
|
||||
</button>
|
||||
</noscript>
|
||||
</form>
|
||||
|
||||
{docs.error && <p>{docs.error} </p>}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Doc{offline && ` (offline mode)`}</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.results.map(doc => {
|
||||
const docUrl = { pathname: '/doc', query: { id: doc.id } }
|
||||
return (
|
||||
<tr key={doc.id}>
|
||||
<td>
|
||||
<Link
|
||||
href={docUrl}
|
||||
as={{ ...docUrl, pathname: addBase('/doc') }}
|
||||
>
|
||||
<a>{doc.id}</a>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{new Date(doc.updated).toLocaleDateString(
|
||||
date.locale,
|
||||
date.options
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!docs.total && <p>No docs found...</p>}
|
||||
|
||||
{docs.total > defDocsLimit && (
|
||||
<Paginate
|
||||
previousLabel="Prev"
|
||||
pageCount={pageCount}
|
||||
marginPagesDisplayed={2}
|
||||
activeClassName="active"
|
||||
forcePage={curPage - 1}
|
||||
onPageChange={handlePage}
|
||||
containerClassName="paginate"
|
||||
hrefBuilder={pg => addBase(`/?page=${pg}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.container {
|
||||
max-width: 750px;
|
||||
margin: 15px auto;
|
||||
}
|
||||
|
||||
.row input {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
td a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
th:nth-of-type(2n),
|
||||
td:nth-of-type(2n) {
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(({ docs }) => ({ docs }))(ListDocs)
|
||||
9
src/client/comps/requireUser.js
Normal file
9
src/client/comps/requireUser.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import Login from '../forms/login'
|
||||
|
||||
function RequireUser({ children, user }) {
|
||||
return user.id ? children : <Login doSetup={user.doSetup} />
|
||||
}
|
||||
|
||||
export default connect(({ user }) => ({ user }))(RequireUser)
|
||||
72
src/client/comps/shortcuts.js
Normal file
72
src/client/comps/shortcuts.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from 'react'
|
||||
import Router from 'next/router'
|
||||
import logout from '../util/logout'
|
||||
import addBase from '../../util/addBase'
|
||||
|
||||
/* - keyboard shortcuts
|
||||
g then h -> navigate home
|
||||
g then n -> navigate to new doc
|
||||
g then s -> navigate to settings
|
||||
g then l -> logout
|
||||
e (when on doc page) -> edit doc
|
||||
/ (when on home page) -> focus search
|
||||
ctrl/cmd + enter -> submit new doc (handled in CodeMirror component)
|
||||
*/
|
||||
const keyToUrl = {
|
||||
72: '/',
|
||||
78: '/new',
|
||||
83: '/settings',
|
||||
}
|
||||
const keyToEl = {
|
||||
69: { sel: '#edit', func: 'click' },
|
||||
191: { sel: '.search', func: 'focus' },
|
||||
}
|
||||
const getKey = e => e.which || e.keyCode
|
||||
|
||||
let prevKey
|
||||
|
||||
const handleKeyDown = e => {
|
||||
const tag = e.target.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
const key = getKey(e)
|
||||
if (prevKey === 71) {
|
||||
// prev key was g
|
||||
switch (key) {
|
||||
case 72:
|
||||
case 78:
|
||||
case 83: {
|
||||
const url = keyToUrl[key]
|
||||
Router.push(url, addBase(url))
|
||||
break
|
||||
}
|
||||
case 76: {
|
||||
// logout
|
||||
setTimeout(logout, 1)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
switch (key) {
|
||||
case 69:
|
||||
case 191: {
|
||||
const { sel, func } = keyToEl[key]
|
||||
const el = document.querySelector(sel)
|
||||
if (el) setTimeout(() => el[func](), 1)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
prevKey = key
|
||||
}
|
||||
|
||||
export default function Shortcuts(props) {
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
112
src/client/forms/login.js
Normal file
112
src/client/forms/login.js
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/client/hocs/withRedux.js
Normal file
58
src/client/hocs/withRedux.js
Normal 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} />
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/client/layout/footer.js
Normal file
30
src/client/layout/footer.js
Normal 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
164
src/client/layout/header.js
Normal 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
51
src/client/store.js
Normal 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__']
|
||||
}
|
||||
27
src/client/stores/cacheStore.js
Normal file
27
src/client/stores/cacheStore.js
Normal 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
|
||||
}
|
||||
}
|
||||
51
src/client/stores/docsStore.js
Normal file
51
src/client/stores/docsStore.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/client/stores/userStore.js
Normal file
28
src/client/stores/userStore.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
488
src/client/styles/codemirror/codemirror.js
Normal file
488
src/client/styles/codemirror/codemirror.js
Normal 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>
|
||||
)
|
||||
}
|
||||
117
src/client/styles/codemirror/monokai.js
Normal file
117
src/client/styles/codemirror/monokai.js
Normal 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
138
src/client/styles/global.js
Normal 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>
|
||||
)
|
||||
}
|
||||
539
src/client/styles/milligram.js
Normal file
539
src/client/styles/milligram.js
Normal 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
304
src/client/styles/roboto.js
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
50
src/client/util/checkLogin.js
Normal file
50
src/client/util/checkLogin.js
Normal 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,
|
||||
})
|
||||
}
|
||||
63
src/client/util/docHelpers.js
Normal file
63
src/client/util/docHelpers.js
Normal 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))
|
||||
})
|
||||
}
|
||||
15
src/client/util/getHeaders.js
Normal file
15
src/client/util/getHeaders.js
Normal 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}`,
|
||||
}
|
||||
}
|
||||
92
src/client/util/loadDocs.js
Normal file
92
src/client/util/loadDocs.js
Normal 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
17
src/client/util/logout.js
Normal 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
37
src/client/util/offlineFallback.js
Normal file
37
src/client/util/offlineFallback.js
Normal 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)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
8
src/client/util/registerServiceWorker.js
Normal file
8
src/client/util/registerServiceWorker.js
Normal 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}`))
|
||||
}
|
||||
})()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
const Spinner = props => <div className="spinner" {...props} />
|
||||
export default Spinner
|
||||
@@ -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)
|
||||
// }
|
||||
}
|
||||
}
|
||||
67
src/index.js
67
src/index.js
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
90
src/server/middleware.js
Normal 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
33
src/server/routes/auth.js
Normal 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
168
src/server/routes/docs.js
Normal 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)
|
||||
})
|
||||
}
|
||||
11
src/server/routes/logout.js
Normal file
11
src/server/routes/logout.js
Normal 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('/'))
|
||||
})
|
||||
}
|
||||
38
src/server/routes/register.js
Normal file
38
src/server/routes/register.js
Normal 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
44
src/server/routes/user.js
Normal 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
60
src/server/server.js
Normal 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
31
src/server/util/config.js
Normal 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
24
src/server/util/db.js
Normal 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)
|
||||
})
|
||||
}
|
||||
29
src/server/util/handleLogin.js
Normal file
29
src/server/util/handleLogin.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
34
src/server/util/isOkDoc.js
Normal file
34
src/server/util/isOkDoc.js
Normal 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
|
||||
}
|
||||
19
src/server/util/jwtExtract.js
Normal file
19
src/server/util/jwtExtract.js
Normal 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
94
src/server/util/kb.js
Normal 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]
|
||||
})
|
||||
}
|
||||
17
src/server/util/requireUser.js
Normal file
17
src/server/util/requireUser.js
Normal 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
|
||||
}
|
||||
30
src/server/util/responses.js
Normal file
30
src/server/util/responses.js
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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) => {
|
||||
21
src/server/util/tryRmdir.js
Normal file
21
src/server/util/tryRmdir.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: [],
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}`)
|
||||
@@ -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; }
|
||||
`)
|
||||
@@ -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
@@ -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
15
src/util/addBase.js
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default req => {
|
||||
if (req) return req.jwt
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.localStorage.getItem('jwt')
|
||||
}
|
||||
}
|
||||
@@ -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
25
src/util/isOkDirPart.js
Normal 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
141
src/util/kbHelpers.js
Normal 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,
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
getKey: e => e.which || e.keyCode,
|
||||
isCtrlKey: key => key === 91 || key === 93 || key === 17,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export default ({ user }) => ({ user })
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
4
src/util/pubConfig.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const config =
|
||||
typeof window !== 'undefined' ? window.publicConfig : global.publicConfig
|
||||
|
||||
module.exports = config
|
||||
@@ -1,8 +0,0 @@
|
||||
const pathPrefix = require('./pathPrefix')
|
||||
|
||||
module.exports = url => {
|
||||
if (pathPrefix !== '/') {
|
||||
url = url.split(pathPrefix).join('')
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default function updateStateFromId(e) {
|
||||
const el = e.target
|
||||
this.setState({ [el.id]: el.value })
|
||||
}
|
||||
Reference in New Issue
Block a user