├── bin
├── README.md
└── backroad.js
├── doc
└── README.md
├── admin
├── README.md
├── postcss.config.js
├── src
│ ├── assets
│ │ ├── scss
│ │ │ ├── partials
│ │ │ │ ├── _layout.scss
│ │ │ │ ├── _footer.scss
│ │ │ │ ├── _home.scss
│ │ │ │ ├── _type.scss
│ │ │ │ ├── _back.scss
│ │ │ │ ├── _bg.scss
│ │ │ │ ├── _view.scss
│ │ │ │ ├── _login.scss
│ │ │ │ ├── _fields.scss
│ │ │ │ ├── _user-menu.scss
│ │ │ │ ├── _sidebar.scss
│ │ │ │ ├── _header.scss
│ │ │ │ └── _tables.scss
│ │ │ └── admin.scss
│ │ └── img
│ │ │ ├── bg-1.jpg
│ │ │ ├── bg-2.jpg
│ │ │ ├── bg-3.jpg
│ │ │ ├── bg-4.jpg
│ │ │ └── bg-5.jpg
│ ├── components
│ │ ├── layout
│ │ │ ├── Footer.js
│ │ │ ├── Sidebar.js
│ │ │ ├── Header.js
│ │ │ └── UserMenu.js
│ │ ├── views
│ │ │ ├── NotFound.js
│ │ │ ├── ManageUsers.js
│ │ │ ├── Home.js
│ │ │ ├── NewUser.js
│ │ │ ├── ManageArticles.js
│ │ │ ├── NewArticle.js
│ │ │ ├── EditUser.js
│ │ │ └── EditArticle.js
│ │ ├── elements
│ │ │ ├── LogoutLink.js
│ │ │ ├── RandomBackground.js
│ │ │ ├── BackButton.js
│ │ │ ├── Alert.js
│ │ │ ├── Search.js
│ │ │ ├── Button.js
│ │ │ ├── NavItem.js
│ │ │ ├── Notice.js
│ │ │ ├── PrimaryNav.js
│ │ │ └── Logo.js
│ │ ├── tables
│ │ │ ├── ArticlesTable.js
│ │ │ ├── UsersTable.js
│ │ │ ├── ArticleRow.js
│ │ │ ├── UserRow.js
│ │ │ ├── ManageTable.js
│ │ │ └── RecentContentTable.js
│ │ ├── fields
│ │ │ ├── Checkbox.js
│ │ │ ├── TextArea.js
│ │ │ ├── SelectMenu.js
│ │ │ ├── RadioGroup.js
│ │ │ ├── Input.js
│ │ │ ├── Fields.js
│ │ │ ├── MarkdownEditor.js
│ │ │ └── RichTextEditor.js
│ │ └── forms
│ │ │ ├── ArticleForm.js
│ │ │ ├── LoginForm.js
│ │ │ ├── UserForm.js
│ │ │ └── EditForm.js
│ ├── utils
│ │ ├── user.js
│ │ ├── authorized.js
│ │ └── data.js
│ ├── containers
│ │ ├── Admin.js
│ │ ├── PrivateRoute.js
│ │ ├── Login.js
│ │ ├── AdminRoute.js
│ │ └── View.js
│ ├── index.js
│ ├── store
│ │ ├── index.js
│ │ ├── config.js
│ │ ├── notice.js
│ │ └── auth.js
│ └── App.js
├── .eslintrc
├── public
│ └── README.md
└── webpack.config.js
├── lib
├── README.md
├── assets
│ ├── scss
│ │ ├── partials
│ │ │ ├── _buttons.scss
│ │ │ ├── _forms.scss
│ │ │ └── _notifications.scss
│ │ ├── backroad.scss
│ │ └── utils
│ │ │ └── _variables.scss
│ └── README.md
├── utils
│ ├── timing.js
│ └── formatting.js
├── server
│ ├── models
│ │ ├── setting.js
│ │ ├── article.js
│ │ └── user.js
│ ├── views
│ │ ├── admin.hbs
│ │ └── install.hbs
│ ├── routes
│ │ ├── config.js
│ │ ├── admin.js
│ │ ├── auth.js
│ │ ├── content-types.js
│ │ ├── install.js
│ │ ├── articles.js
│ │ └── users.js
│ └── index.js
├── content-types.js
└── backroad.js
├── install
├── README.md
├── postcss.config.js
├── .eslintrc
├── src
│ └── assets
│ │ ├── scss
│ │ ├── install.scss
│ │ └── partials
│ │ │ └── _layout.scss
│ │ └── js
│ │ └── app.js
├── public
│ └── README.md
└── webpack.config.js
├── example
├── README.md
└── basic
│ ├── .env
│ ├── package.json
│ ├── package-lock.json
│ ├── index.js
│ └── content-types.js
├── index.js
├── screenshot-1.jpg
├── screenshot-2.jpg
├── screenshot-3.jpg
├── screenshot-4.jpg
├── screenshot-5.jpg
├── screenshot-6.jpg
├── screenshot-7.jpg
├── screenshot-8.jpg
├── .editorconfig
├── dist
└── README.md
├── .gitignore
├── package.json
└── README.md
/bin/README.md:
--------------------------------------------------------------------------------
1 | # CLI Tools
2 |
--------------------------------------------------------------------------------
/doc/README.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
--------------------------------------------------------------------------------
/admin/README.md:
--------------------------------------------------------------------------------
1 | # Admin Interface
2 |
--------------------------------------------------------------------------------
/lib/README.md:
--------------------------------------------------------------------------------
1 | # Core Functionality
2 |
--------------------------------------------------------------------------------
/bin/backroad.js:
--------------------------------------------------------------------------------
1 | // `backroad` command ...
2 |
--------------------------------------------------------------------------------
/install/README.md:
--------------------------------------------------------------------------------
1 | # Installation Interface
2 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # Working Examples and Use Cases
2 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = require('./lib/backroad');
4 |
--------------------------------------------------------------------------------
/lib/assets/scss/partials/_buttons.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Buttons
3 | //
4 |
5 | // ...
6 |
--------------------------------------------------------------------------------
/screenshot-1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-1.jpg
--------------------------------------------------------------------------------
/screenshot-2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-2.jpg
--------------------------------------------------------------------------------
/screenshot-3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-3.jpg
--------------------------------------------------------------------------------
/screenshot-4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-4.jpg
--------------------------------------------------------------------------------
/screenshot-5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-5.jpg
--------------------------------------------------------------------------------
/screenshot-6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-6.jpg
--------------------------------------------------------------------------------
/screenshot-7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-7.jpg
--------------------------------------------------------------------------------
/screenshot-8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/screenshot-8.jpg
--------------------------------------------------------------------------------
/admin/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require('autoprefixer')]
3 | };
4 |
--------------------------------------------------------------------------------
/example/basic/.env:
--------------------------------------------------------------------------------
1 | SECRET=Sz14gJaNZV=CPO6?2M+-lbz2HG|R 7w#P,v8N J9IS5[GtUOK:Qro+RRR+-epi2|
2 |
--------------------------------------------------------------------------------
/install/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require('autoprefixer')]
3 | };
4 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_layout.scss:
--------------------------------------------------------------------------------
1 | body {
2 | background: $admin-body-bg-color;
3 | }
4 |
--------------------------------------------------------------------------------
/admin/src/assets/img/bg-1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/admin/src/assets/img/bg-1.jpg
--------------------------------------------------------------------------------
/admin/src/assets/img/bg-2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/admin/src/assets/img/bg-2.jpg
--------------------------------------------------------------------------------
/admin/src/assets/img/bg-3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/admin/src/assets/img/bg-3.jpg
--------------------------------------------------------------------------------
/admin/src/assets/img/bg-4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/admin/src/assets/img/bg-4.jpg
--------------------------------------------------------------------------------
/admin/src/assets/img/bg-5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/themeblvd/backroad/HEAD/admin/src/assets/img/bg-5.jpg
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_footer.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Footer
3 | //
4 |
5 | .app-footer {
6 | // ...
7 | }
8 |
--------------------------------------------------------------------------------
/lib/assets/README.md:
--------------------------------------------------------------------------------
1 | # Shared Assets
2 |
3 | This directory contains assets shared between client interfaces.
4 |
--------------------------------------------------------------------------------
/admin/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["react-app", "plugin:jsx-a11y/recommended"],
3 | "plugins": ["jsx-a11y"]
4 | }
5 |
--------------------------------------------------------------------------------
/install/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["react-app", "plugin:jsx-a11y/recommended"],
3 | "plugins": ["jsx-a11y"]
4 | }
5 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_home.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Home Header
3 | //
4 |
5 | .home-top {
6 | margin: 0 0 30px 10px;
7 |
8 | h1 {
9 | font-size: 28px;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_type.scss:
--------------------------------------------------------------------------------
1 | //
2 | // View Headings
3 | //
4 |
5 | .view-title {
6 | font-weight: 100;
7 | letter-spacing: 1px;
8 | margin-bottom: 40px;
9 | }
10 |
--------------------------------------------------------------------------------
/install/src/assets/scss/install.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Shared
3 | //
4 |
5 | @import '../../../../lib/assets/scss/backroad.scss';
6 |
7 | //
8 | // Install Page
9 | //
10 |
11 | @import 'partials/layout';
12 |
--------------------------------------------------------------------------------
/admin/public/README.md:
--------------------------------------------------------------------------------
1 | When running `@TODO` during development, the static admin client files are rendered to, and served from, this directory.
2 |
3 | You can view the development server at http://localhost/5050.
4 |
--------------------------------------------------------------------------------
/install/public/README.md:
--------------------------------------------------------------------------------
1 | When running `@TODO` during development, the static install client files are rendered to, and served from, this directory.
2 |
3 | You can view the development server at http://localhost/5050.
4 |
--------------------------------------------------------------------------------
/admin/src/components/layout/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Footer = () => {
4 | return (
5 |
8 | );
9 | };
10 |
11 | export default Footer;
12 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [**/*.js]
4 | indent_style = space
5 | indent_size = 2
6 | quote_type = single
7 | max_line_length = 100
8 |
9 | [**/*.scss]
10 | indent_size = 2
11 | max_line_length = 100
12 |
13 | [*.json]
14 | indent_size = 2
15 | max_line_length = 200
16 |
--------------------------------------------------------------------------------
/admin/src/components/layout/Sidebar.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PrimaryNav from '../elements/PrimaryNav';
3 |
4 | const Sidebar = () => {
5 | return (
6 |
9 | );
10 | };
11 |
12 | export default Sidebar;
13 |
--------------------------------------------------------------------------------
/dist/README.md:
--------------------------------------------------------------------------------
1 | Running `@TODO` will compile the final production build here. This includes a `server.js` file, along with a `/admin` and `/install` directories containing the static client-side files that it'll serve.
2 |
3 | You can test the production build with `@TODO` and viewing at `http://localhost/5050`.
4 |
--------------------------------------------------------------------------------
/example/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "basic",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node index.js"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "GPL-2.0+",
12 | "dependencies": {
13 | "dotenv": "^6.0.0"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_back.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Back Button
3 | //
4 |
5 | .btn-back.btn-default {
6 | background: #fff;
7 | box-shadow: $shadow-dark;
8 | position: fixed;
9 | top: $admin-header-height + 20px;
10 | left: $admin-sidebar-width + 20px;
11 |
12 | &:hover {
13 | background: $primary-color;
14 | color: #fff;
15 | }
16 | .icon {
17 | margin-right: 10px;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/admin/src/utils/user.js:
--------------------------------------------------------------------------------
1 | import md5 from 'md5';
2 |
3 | /**
4 | * Get gravatar image URL.
5 | *
6 | * @param {String} email Email address.
7 | * @return {String} Gravatar image URL.
8 | */
9 | export function gravatar(email) {
10 | email = email.replace(/\s/g, ''); // Remove whitespace.
11 | email = email.toLowerCase();
12 | return `https://www.gravatar.com/avatar/${md5(email)}.jpg?default=mp`;
13 | }
14 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_bg.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Random Background
3 | //
4 |
5 | .bg-random {
6 | overflow: hidden;
7 | position: fixed;
8 | top: 0;
9 | right: 0;
10 | bottom: 0;
11 | left: 0;
12 |
13 | img {
14 | position: absolute;
15 | top: 50%;
16 | left: 50%;
17 | width: 100%;
18 | height: 100%;
19 | object-fit: cover;
20 | transform: translate(-50%, -50%);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/example/basic/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "basic",
3 | "version": "1.0.0",
4 | "lockfileVersion": 1,
5 | "requires": true,
6 | "dependencies": {
7 | "dotenv": {
8 | "version": "6.0.0",
9 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.0.0.tgz",
10 | "integrity": "sha512-FlWbnhgjtwD+uNLUGHbMykMOYQaTivdHEmYwAKFjn6GKe/CqY0fNae93ZHTd20snh9ZLr8mTzIL9m0APQ1pjQg=="
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/admin/src/components/views/NotFound.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const NotFound = () => {
4 | return (
5 |
6 |
7 |
Page Not Found
8 |
9 | {"Oops! The page you're trying to reach doesn't exist. How embarrassing."}
10 |
11 |
12 |
13 | );
14 | };
15 |
16 | export default NotFound;
17 |
--------------------------------------------------------------------------------
/admin/src/containers/Admin.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Header from '../components/layout/Header';
3 | import UserMenu from '../components/layout/UserMenu';
4 | import Sidebar from '../components/layout/Sidebar';
5 | import View from './View';
6 |
7 | const Admin = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | };
17 |
18 | export default Admin;
19 |
--------------------------------------------------------------------------------
/example/basic/index.js:
--------------------------------------------------------------------------------
1 | const env = require('dotenv').config();
2 | const backroad = require('../..'); // require('backroad')
3 | const { post, page, book } = require('./content-types');
4 |
5 | /**
6 | * Create new application.
7 | */
8 | const app = backroad();
9 |
10 | /**
11 | * Add custom content types.
12 | */
13 | app.contentTypes.add(post);
14 | app.contentTypes.add(page);
15 | app.contentTypes.add(book);
16 |
17 | /**
18 | * Run the application!
19 | */
20 | app.start();
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # reports
7 | report-client.txt
8 | report-server.txt
9 |
10 | # client
11 | admin/public/*
12 | !admin/public/README.md
13 | install/public/*
14 | !install/public/README.md
15 |
16 | # build
17 | dist/*
18 | !dist/README.md
19 |
20 | # misc
21 | .DS_Store
22 | .env.local
23 | .env.development.local
24 | .env.test.local
25 | .env.production.local
26 | .tags
27 | .tags1
28 |
29 | npm-debug.log*
30 |
--------------------------------------------------------------------------------
/lib/utils/timing.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | /**
3 | * Timeout promise.
4 | *
5 | * This allows us to change timeouts together
6 | * with a promise.
7 | *
8 | * @param {Number} delay Time delay in milliseconds.
9 | * @param {Mixed} dataToPass Data to pass along the chain.
10 | */
11 | timeoutPromise: function(delay, dataToPass) {
12 | return new Promise(function(resolve, reject) {
13 | setTimeout(function() {
14 | resolve(dataToPass);
15 | }, delay);
16 | });
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/admin.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Shared
3 | //
4 |
5 | @import '../../../../lib/assets/scss/backroad.scss';
6 |
7 | //
8 | // Admin
9 | //
10 |
11 | @import 'partials/type';
12 | @import 'partials/layout';
13 | @import 'partials/bg';
14 | @import 'partials/back';
15 | @import 'partials/header';
16 | @import 'partials/user-menu';
17 | @import 'partials/sidebar';
18 | @import 'partials/view';
19 | @import 'partials/footer';
20 | @import 'partials/fields';
21 | @import 'partials/tables';
22 | @import 'partials/home';
23 | @import 'partials/login';
24 |
--------------------------------------------------------------------------------
/admin/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | // Routing
5 | import { BrowserRouter } from 'react-router-dom';
6 |
7 | // Store
8 | import { Provider } from 'react-redux';
9 | import store from './store';
10 |
11 | // Application
12 | import './assets/scss/admin.scss';
13 | import App from './App';
14 |
15 | ReactDOM.render(
16 |
17 |
18 |
19 |
20 | ,
21 | document.getElementById('root')
22 | );
23 |
--------------------------------------------------------------------------------
/admin/src/components/views/ManageUsers.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ManageTable from '../tables/ManageTable';
3 | import UsersTable from '../tables/UsersTable';
4 |
5 | /**
6 | * Manage Users
7 | *
8 | * @return {Component}
9 | */
10 | const ManageUsers = props => {
11 | return (
12 |
17 | );
18 | };
19 |
20 | export default ManageUsers;
21 |
--------------------------------------------------------------------------------
/lib/assets/scss/backroad.scss:
--------------------------------------------------------------------------------
1 | // Required by Front Street
2 | $direction: 'ltr';
3 | $version: 'core';
4 |
5 | // Front Street Functions, Variables and Mixins
6 | @import '~frontstreet/src/scss/utils/index';
7 |
8 | // Back Road configuration
9 | @import 'utils/variables';
10 |
11 | // Front Street Core
12 | @import '~frontstreet/src/scss/core/index';
13 |
14 | // Front Street Blocks
15 | @import '~frontstreet/src/scss/blocks/alert';
16 |
17 | // Back Road Core
18 | @import 'partials/buttons';
19 | @import 'partials/forms';
20 | @import 'partials/notifications';
21 |
--------------------------------------------------------------------------------
/admin/src/containers/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Route, Redirect } from 'react-router-dom';
4 |
5 | /**
6 | * Private Route
7 | *
8 | * This is a wrapper for that
9 | * restricts access to logged-in users.
10 | *
11 | * @return {Component}
12 | */
13 | const PrivateRoute = props => {
14 | const { isAuthenticated, path, component } = props;
15 | return isAuthenticated ? : ;
16 | };
17 |
18 | export default connect(state => state.auth)(PrivateRoute);
19 |
--------------------------------------------------------------------------------
/install/src/assets/scss/partials/_layout.scss:
--------------------------------------------------------------------------------
1 | body {
2 | background: $install-body-bg-color;
3 | }
4 |
5 | .install-page {
6 | > .wrap {
7 | padding: 0 0 50px 0;
8 | margin: 0 auto;
9 | max-width: 600px;
10 | }
11 | .install-logo {
12 | padding: 80px 0 60px 0;
13 | }
14 | .logo {
15 | display: block;
16 | fill: #fff;
17 | margin: 0 auto;
18 | width: 180px;
19 | }
20 | form {
21 | background: $install-inner-bg-color;
22 | border-radius: 4px;
23 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.75);
24 | padding: 60px 80px;
25 | margin: 0;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/admin/src/components/views/Home.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import RecentContentTable from '../tables/RecentContentTable';
4 |
5 | const Home = props => {
6 | const { first_name, username } = props;
7 |
8 | return (
9 |
10 |
11 |
12 | Welcome, {first_name ? first_name : username}.
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default connect(state => ({ ...state.auth }))(Home);
21 |
--------------------------------------------------------------------------------
/admin/src/components/elements/LogoutLink.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { logout } from '../../store/auth';
4 |
5 | class LogoutLink extends Component {
6 | handleClick = event => {
7 | event.preventDefault();
8 | this.props.closeUserMenu();
9 | this.props.logout();
10 | };
11 |
12 | render() {
13 | const { children } = this.props;
14 | return (
15 |
16 | {children}
17 |
18 | );
19 | }
20 | }
21 |
22 | export default connect(
23 | null,
24 | { logout }
25 | )(LogoutLink);
26 |
--------------------------------------------------------------------------------
/admin/src/containers/Login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Logo from '../components/elements/Logo';
3 | import LoginForm from '../components/forms/LoginForm';
4 | import RandomBackground from '../components/elements/RandomBackground';
5 |
6 | const Login = () => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default Login;
23 |
--------------------------------------------------------------------------------
/admin/src/components/elements/RandomBackground.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import bg1 from '../../assets/img/bg-1.jpg';
3 | import bg2 from '../../assets/img/bg-2.jpg';
4 | import bg3 from '../../assets/img/bg-3.jpg';
5 | import bg4 from '../../assets/img/bg-4.jpg';
6 | import bg5 from '../../assets/img/bg-5.jpg';
7 |
8 | const RandomBackground = () => {
9 | const randomIndex = Math.floor(Math.random() * (4 - 0) + 0); // 0 - 4
10 | const images = [bg1, bg2, bg3, bg4, bg5];
11 |
12 | return (
13 |
14 |

15 |
16 | );
17 | };
18 |
19 | export default RandomBackground;
20 |
--------------------------------------------------------------------------------
/lib/server/models/setting.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | const Schema = mongoose.Schema;
3 |
4 | /**
5 | * Creates a Mongoose schema for a setting
6 | * in the database.
7 | */
8 | function createSettingSchema(config) {
9 | const schema = new Schema(
10 | {
11 | name: {
12 | type: String,
13 | unique: true,
14 | required: true
15 | },
16 | value: {
17 | type: Schema.Types.Mixed
18 | }
19 | },
20 | { collection: 'settings' }
21 | );
22 |
23 | return mongoose.model('Setting', schema);
24 | }
25 |
26 | /**
27 | * Export factory function.
28 | */
29 | module.exports = createSettingSchema;
30 |
--------------------------------------------------------------------------------
/admin/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, combineReducers } from 'redux';
2 | import thunk from 'redux-thunk';
3 |
4 | import auth from './auth';
5 | import notice from './notice';
6 | import config from './config';
7 |
8 | const reducer = combineReducers({
9 | auth,
10 | notice,
11 | config
12 | });
13 |
14 | var store;
15 |
16 | if (process.env.NODE_ENV === 'development') {
17 | store = createStore(
18 | reducer,
19 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
20 | applyMiddleware(thunk)
21 | );
22 | } else {
23 | store = createStore(reducer, applyMiddleware(thunk));
24 | }
25 |
26 | export default store;
27 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_view.scss:
--------------------------------------------------------------------------------
1 | //
2 | // View
3 | //
4 |
5 | .app-view {
6 | overflow: scroll;
7 | position: fixed;
8 | top: $admin-header-height;
9 | right: 0;
10 | bottom: 0;
11 | left: $admin-sidebar-width;
12 | }
13 |
14 | //
15 | // Narrow Views
16 | //
17 |
18 | .new-user-view,
19 | .edit-user-view,
20 | .new-article-view,
21 | .edit-article-view,
22 | .not-found-view {
23 | > .wrap {
24 | max-width: 1000px;
25 | margin: 0 auto;
26 | padding: 50px;
27 | }
28 | }
29 |
30 | //
31 | // Wide Views
32 | //
33 |
34 | .home-view,
35 | .manage-users-view,
36 | .manage-articles-view {
37 | max-width: 1400px;
38 | margin: 0 auto;
39 | padding: 50px;
40 | }
41 |
--------------------------------------------------------------------------------
/lib/server/views/admin.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{title}}
7 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/admin/src/utils/authorized.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | /**
4 | * Create authorized axios instance.
5 | *
6 | * This allows us to pass around a single
7 | * instance of axios, which has the logged-in
8 | * user's authentication token in the header.
9 | *
10 | * @return {Object} Axios instance.
11 | */
12 | function authorized() {
13 | const authorized = axios.create();
14 |
15 | authorized.interceptors.request.use(function(config) {
16 | const token = localStorage.getItem('token');
17 |
18 | if (token) {
19 | config.headers.Authorization = `Bearer ${token}`;
20 | }
21 |
22 | return config;
23 | });
24 |
25 | return authorized;
26 | }
27 |
28 | export default authorized();
29 |
--------------------------------------------------------------------------------
/admin/src/components/views/NewUser.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { singularTitle } from '../../utils/data';
3 | import BackButton from '../elements/BackButton';
4 | import EditForm from '../forms/EditForm';
5 | import UserForm from '../forms/UserForm';
6 |
7 | /**
8 | * Add a new user.
9 | *
10 | * @return {Component}
11 | */
12 | const newUser = props => {
13 | return (
14 |
15 |
16 |
New {singularTitle('users')}
17 |
18 | } />
19 |
20 |
21 | );
22 | };
23 |
24 | export default newUser;
25 |
--------------------------------------------------------------------------------
/admin/src/containers/AdminRoute.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Route, Redirect } from 'react-router-dom';
4 |
5 | /**
6 | * Admin Route
7 | *
8 | * Restricts access to routes that careful
9 | * only accessible by and admin-level user.
10 | *
11 | * NOTE: A non-logged user will never get
12 | * to this component; so only a check for
13 | * the role of current user is needed.
14 | *
15 | * @return {Component}
16 | */
17 | const AdminRoute = props => {
18 | const { isAdmin, path, component } = props;
19 | return isAdmin ? : ;
20 | };
21 |
22 | export default connect(state => ({ isAdmin: state.auth.role === 'admin' }))(AdminRoute);
23 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_login.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Login Page
3 | //
4 |
5 | .login-page {
6 | background: $bg-color-inverse;
7 | min-height: 100vh;
8 |
9 | > .wrap {
10 | margin: 0 auto;
11 | max-width: 450px;
12 | padding: 40px 0 0 0;
13 | }
14 | .login-logo {
15 | padding: 40px;
16 | position: relative;
17 | text-align: center;
18 | z-index: 2;
19 | }
20 | .login-logo .logo {
21 | fill: #fff;
22 | width: 160px;
23 | }
24 | .login-form {
25 | background: $login-form-bg-color;
26 | border-radius: 4px;
27 | box-shadow: 0 0 50px rgba(0, 0, 0, 0.25);
28 | padding: 50px;
29 | position: relative;
30 | z-index: 2;
31 | }
32 | .bg-random {
33 | opacity: 0.5;
34 | z-index: 1;
35 | }
36 | .bg-random img {
37 | filter: blur(50px);
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/admin/src/components/elements/BackButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
4 | import faLongArrowAltLeft from '@fortawesome/fontawesome-free-solid/faLongArrowAltLeft';
5 |
6 | /**
7 | * Back Button
8 | *
9 | * Displayed when editing or adding users
10 | * and documents, to go back to the respective
11 | * management table.
12 | *
13 | * @param {Object} props Component props.
14 | * @param {Function} props.to Where to redirect.
15 | * @return {Component}
16 | */
17 | const BackButton = props => {
18 | const { to } = props;
19 |
20 | return (
21 |
22 |
23 | Back
24 |
25 | );
26 | };
27 |
28 | export default BackButton;
29 |
--------------------------------------------------------------------------------
/admin/src/store/config.js:
--------------------------------------------------------------------------------
1 | import authorized from '../utils/authorized.js';
2 |
3 | // Intial State
4 |
5 | const initialState = {
6 | docTypes: [],
7 | optionGroups: []
8 | };
9 |
10 | // Action Types
11 |
12 | const LOAD_CONFIG = 'LOAD_CONFIG';
13 |
14 | // Reducer
15 |
16 | export default function reducer(state = initialState, action) {
17 | switch (action.type) {
18 | case LOAD_CONFIG:
19 | return { ...action.config };
20 |
21 | default:
22 | return state;
23 | }
24 | }
25 |
26 | // Actions
27 |
28 | export function loadConfig() {
29 | return dispatch => {
30 | authorized
31 | .get('/api/v1/config')
32 | .then(response => {
33 | dispatch({
34 | type: LOAD_CONFIG,
35 | config: response.data
36 | });
37 | })
38 | .catch(err => {
39 | console.log(err);
40 | });
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/admin/src/components/elements/Alert.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | * Alert
5 | *
6 | * This is for displaying messages within
7 | * components like forms, not to be confused
8 | * with the "Notice" component which is for
9 | * top-level application notices.
10 | *
11 | * @param {Object} props Component props.
12 | * @param {String} props.status Status, `sucess`, `info`, `warning`, or `danger`
13 | * @param {String} props.title Optional. Heading for alert.
14 | * @param {String} props.text Content of alert.
15 | * @return {Component}
16 | */
17 | const Alert = props => {
18 | const { status, title, text } = props;
19 |
20 | return (
21 |
22 |
23 | {title && {title + ' '}}
24 | {text}
25 |
26 |
27 | );
28 | };
29 |
30 | export default Alert;
31 |
--------------------------------------------------------------------------------
/admin/src/components/views/ManageArticles.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { withRouter } from 'react-router-dom';
3 | import { getPathBase, isValidContentType } from '../../utils/data';
4 | import NotFound from './NotFound';
5 | import ManageTable from '../tables/ManageTable';
6 | import ArticlesTable from '../tables/ArticlesTable';
7 |
8 | /**
9 | * Manage Articles
10 | *
11 | * @return {Component}
12 | */
13 | const ManageArticles = props => {
14 | const { location } = props;
15 | const type = getPathBase(location.pathname);
16 |
17 | if (!isValidContentType(type)) {
18 | return ;
19 | }
20 |
21 | return (
22 |
27 | );
28 | };
29 |
30 | export default withRouter(ManageArticles);
31 |
--------------------------------------------------------------------------------
/admin/src/components/views/NewArticle.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { withRouter } from 'react-router-dom';
3 | import { singularTitle, getPathBase } from '../../utils/data';
4 | import BackButton from '../elements/BackButton';
5 | import EditForm from '../forms/EditForm';
6 | import ArticleForm from '../forms/ArticleForm';
7 |
8 | /**
9 | * Add a new document (of any content
10 | * type).
11 | *
12 | * @return {Component}
13 | */
14 | const NewDoc = props => {
15 | const { type } = props.match.params;
16 | const title = 'New ' + singularTitle(type);
17 | return (
18 |
19 |
20 |
{title}
21 |
22 |
} />
23 |
24 |
25 | );
26 | };
27 |
28 | export default withRouter(NewDoc);
29 |
--------------------------------------------------------------------------------
/admin/src/store/notice.js:
--------------------------------------------------------------------------------
1 | // Intial State
2 | const initialState = {
3 | hasNotice: false,
4 | message: '',
5 | type: ''
6 | };
7 |
8 | // Action Types
9 |
10 | const ADD_NOTICE = 'ADD_NOTICE';
11 |
12 | const REMOVE_NOTICE = 'REMOVE_NOTICE';
13 |
14 | // Reducer
15 |
16 | export default function reducer(state = initialState, action) {
17 | switch (action.type) {
18 | case ADD_NOTICE:
19 | return {
20 | hasNotice: true,
21 | message: action.noticeMessage,
22 | type: action.noticeType
23 | };
24 |
25 | case REMOVE_NOTICE:
26 | return initialState;
27 |
28 | default:
29 | return state;
30 | }
31 | }
32 |
33 | // Actions
34 |
35 | export function addNotice(noticeMessage, noticeType) {
36 | return {
37 | type: ADD_NOTICE,
38 | noticeMessage,
39 | noticeType
40 | };
41 | }
42 |
43 | export function removeNotice() {
44 | return {
45 | type: REMOVE_NOTICE
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/admin/src/components/tables/ArticlesTable.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ArticleRow from './ArticleRow';
3 |
4 | /**
5 | * Users Table
6 | *
7 | * @return {Component}
8 | */
9 | const ArticlesTable = props => {
10 | const { items, type } = props;
11 |
12 | return (
13 |
14 |
15 |
16 | | Title |
17 | Created |
18 | Created By |
19 | Actions |
20 |
21 |
22 |
23 | {items.map(item => (
24 |
33 | ))}
34 |
35 |
36 | );
37 | };
38 |
39 | export default ArticlesTable;
40 |
--------------------------------------------------------------------------------
/admin/src/components/tables/UsersTable.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import UserRow from './UserRow';
3 |
4 | /**
5 | * Users Table
6 | *
7 | * @return {Component}
8 | */
9 | const UsersTable = props => {
10 | const { items } = props;
11 |
12 | return (
13 |
14 |
15 |
16 | | Username |
17 | Name |
18 | Email |
19 | Role |
20 | Actions |
21 |
22 |
23 |
24 | {items.map(item => (
25 |
34 | ))}
35 |
36 |
37 | );
38 | };
39 |
40 | export default UsersTable;
41 |
--------------------------------------------------------------------------------
/admin/src/components/views/EditUser.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { withRouter } from 'react-router-dom';
3 | import { connect } from 'react-redux';
4 | import { singularTitle } from '../../utils/data';
5 | import EditForm from '../forms/EditForm';
6 | import BackButton from '../elements/BackButton';
7 | import UserForm from '../forms/UserForm';
8 |
9 | /**
10 | * Edit a user.
11 | *
12 | * @return {Component}
13 | */
14 | const EditUser = props => {
15 | const username = props.match.params.username ? props.match.params.username : props.currentUser;
16 |
17 | return (
18 |
19 |
20 |
Edit {singularTitle('users')}
21 |
22 | } />
23 |
24 |
25 | );
26 | };
27 |
28 | export default withRouter(connect(state => ({ currentUser: state.auth.username }))(EditUser));
29 |
--------------------------------------------------------------------------------
/admin/src/components/views/EditArticle.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withRouter } from 'react-router-dom';
3 | import { isValidContentType, singularTitle } from '../../utils/data';
4 | import BackButton from '../elements/BackButton';
5 | import NotFound from './NotFound';
6 | import EditForm from '../forms/EditForm';
7 | import ArticleForm from '../forms/ArticleForm';
8 |
9 | /**
10 | * Edit a document (of any content type).
11 | *
12 | * @return {Component}
13 | */
14 | const EditArticle = props => {
15 | const { type, slug } = props.match.params;
16 |
17 | if (!isValidContentType(type)) {
18 | return ;
19 | }
20 |
21 | return (
22 |
23 |
24 |
Edit {singularTitle(type)}
25 |
26 |
} />
27 |
28 |
29 | );
30 | };
31 |
32 | export default withRouter(EditArticle);
33 |
--------------------------------------------------------------------------------
/lib/assets/scss/partials/_forms.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Fields
3 | //
4 |
5 | #{$fs-field-selector} {
6 | background-color: #fff;
7 | border: none;
8 | border-radius: 4px;
9 | box-shadow: $field-shadow;
10 | color: $text-color-darken;
11 | transition: box-shadow 0.1s ease-in, transform 0.1s ease-in;
12 |
13 | &:disabled {
14 | background: #f5f5f5;
15 | color: lighten($text-color-lighten, 10%);
16 | font-style: italic;
17 | }
18 | &:focus {
19 | box-shadow: $field-shadow-focus;
20 | }
21 | }
22 |
23 | textarea {
24 | line-height: 1.5;
25 | }
26 |
27 | //
28 | // Labels/Legends
29 | //
30 |
31 | label,
32 | legend {
33 | font-size: 16px;
34 | margin: 0 4px 15px 4px;
35 | }
36 | .help-text {
37 | display: block;
38 | font-size: 14px;
39 | margin-top: -5px;
40 | }
41 |
42 | //
43 | // Field Error
44 | //
45 |
46 | .field-error {
47 | input {
48 | border-color: darken(adjust-hue($danger-color, -10), 20%);
49 | }
50 | }
51 | input.error {
52 | border-color: darken(adjust-hue($danger-color, -10), 20%);
53 | }
54 |
--------------------------------------------------------------------------------
/admin/src/components/elements/Search.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
3 | import faSearch from '@fortawesome/fontawesome-free-solid/faSearch';
4 | import classNames from 'classnames';
5 |
6 | class Search extends Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = { active: false };
10 | }
11 | handleFocus = () => {
12 | this.setState({ active: true });
13 | };
14 | handleBlur = () => {
15 | this.setState({ active: false });
16 | };
17 | render() {
18 | const className = classNames({
19 | 'app-search': true,
20 | 'field-icon': true,
21 | 'field-icon-sm': true,
22 | active: this.state.active
23 | });
24 | return (
25 |
26 |
27 |
28 |
29 | );
30 | }
31 | }
32 |
33 | export default Search;
34 |
--------------------------------------------------------------------------------
/admin/src/components/fields/Checkbox.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | * Single Checkbox
5 | *
6 | * @param {Object} props Component props.
7 | * @param {String} props.value Current value.
8 | * @param {Function} props.onChange Handle input state change.
9 | * @param {String} props.title Optional. Title above input.
10 | * @param {String} props.name Name attribute for input.
11 | * @param {String} props.help, Optional. Help text below input.
12 | * @param {Boolean} props.hasError Whether currently has error.
13 | * @return {Component}
14 | */
15 | const Checkbox = props => {
16 | const { value, onChange, title, name, help, hasError } = props;
17 |
18 | return (
19 |
20 | {title && }
21 |
22 | {help && }
23 |
24 | );
25 | };
26 |
27 | export default Checkbox;
28 |
--------------------------------------------------------------------------------
/lib/server/models/article.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | const Schema = mongoose.Schema;
3 | const slugHero = require('mongoose-slug-hero');
4 |
5 | /**
6 | * Creates a Mongoose schema for an article
7 | * in the database.
8 | */
9 | function createArticleSchema(config) {
10 | const options = {
11 | minimize: false,
12 | collection: 'articles',
13 | timestamps: {
14 | createdAt: 'created_at',
15 | updatedAt: 'updated_at'
16 | }
17 | };
18 |
19 | const model = {
20 | content_type: {
21 | type: 'String',
22 | required: true
23 | },
24 | title: {
25 | type: String,
26 | required: true
27 | },
28 | created_by: {
29 | type: String,
30 | required: true
31 | },
32 | fields: {
33 | type: Object,
34 | default: {}
35 | }
36 | };
37 |
38 | const schema = new Schema(model, options);
39 |
40 | schema.plugin(slugHero, { doc: 'Article', field: 'title' });
41 |
42 | return mongoose.model('Article', schema);
43 | }
44 |
45 | /**
46 | * Export factory function.
47 | */
48 | module.exports = createArticleSchema;
49 |
--------------------------------------------------------------------------------
/admin/src/components/elements/Button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | /**
5 | * Button
6 | *
7 | * @param {Object} props Component props.
8 | * @param {String} props.size Optioonal button size, like `xs`, `sm`, `lg`, `xl`, `xxl`, or `xxxl`.
9 | * @param {Boolean} props.isLoading Whether button has loading animation state.
10 | * @param {Boolean} props.isPrimary Whether button has primary styling.
11 | * @param {Function} props.onClick Button click handler.
12 | * @return {Component}
13 | */
14 | const Button = props => {
15 | const { size, isLoading, isPrimary, onClick, children } = props;
16 |
17 | const classes = classNames({
18 | btn: true,
19 | 'btn-xs': size === 'xs',
20 | 'btn-sm': size === 'sm',
21 | 'btn-lg': size === 'lg',
22 | 'btn-xl': size === 'xl',
23 | 'btn-xxl': size === 'xxl',
24 | 'btn-xxxl': size === 'xxxl',
25 | 'has-loader': true,
26 | 'btn-primary': isPrimary,
27 | 'has-loader': true,
28 | loading: isLoading
29 | });
30 |
31 | return (
32 |
35 | );
36 | };
37 |
38 | export default Button;
39 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_fields.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Title Field
3 | //
4 |
5 | .new-article-view,
6 | .edit-article-view {
7 | .title-field {
8 | margin-bottom: 30px;
9 | }
10 | }
11 |
12 | //
13 | // Editors
14 | //
15 |
16 | .markdown-editor,
17 | .rich-text-editor {
18 | > .wrap {
19 | background-color: $field-bg-color;
20 | border-radius: $field-radius;
21 | box-shadow: $field-shadow;
22 | padding: 30px;
23 | margin-bottom: $fs-element-spacing;
24 | }
25 | .editor {
26 | min-height: 200px;
27 | }
28 | }
29 |
30 | .rich-text-editor {
31 | .toolbar-menu {
32 | border-bottom: 2px solid #eee;
33 | margin: 0 -20px;
34 | margin-bottom: 20px;
35 | padding: 1px 18px 17px;
36 | position: relative;
37 |
38 | .button {
39 | color: lighten($text-color-lighten, 15%);
40 | cursor: pointer;
41 | display: inline-block;
42 | font-size: 18px;
43 | }
44 | .button:hover {
45 | color: $text-color-lighten;
46 | }
47 | .button[data-active='true'] {
48 | color: $text-color-darken;
49 | }
50 | .button span {
51 | margin-left: 15px;
52 | }
53 | .button:first-child span {
54 | margin-left: 0;
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/example/basic/content-types.js:
--------------------------------------------------------------------------------
1 | const post = {
2 | id: 'post',
3 | endpoint: 'posts',
4 | name: 'Post',
5 | pluralName: 'Posts',
6 | fields: [
7 | {
8 | id: 'content',
9 | name: 'Content',
10 | type: 'rich-text',
11 | placeholder: 'Start writing...'
12 | },
13 | {
14 | id: 'category',
15 | type: 'select',
16 | options: ['Tutorials', 'Rants', 'Adventures']
17 | }
18 | ]
19 | };
20 |
21 | const page = {
22 | id: 'page',
23 | endpoint: 'pages',
24 | name: 'Page',
25 | pluralName: 'Pages',
26 | fields: [
27 | {
28 | id: 'content',
29 | type: 'rich-text',
30 | placeholder: 'Start writing...'
31 | }
32 | ]
33 | };
34 |
35 | const book = {
36 | id: 'book',
37 | endpoint: 'books',
38 | name: 'Book',
39 | pluralName: 'Books',
40 | fields: [
41 | {
42 | id: 'author',
43 | name: 'Author',
44 | type: 'text'
45 | },
46 | {
47 | id: 'desc',
48 | name: 'Description',
49 | type: 'textarea'
50 | },
51 | {
52 | id: 'genre',
53 | name: 'Genre',
54 | type: 'select',
55 | default: 'General',
56 | options: ['General', 'Adventure', 'Suspense', 'Romance', 'Comedy']
57 | }
58 | ]
59 | };
60 |
61 | module.exports = { post, page, book };
62 |
--------------------------------------------------------------------------------
/admin/src/components/layout/Header.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { gravatar } from '../../utils/user';
4 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
5 | import faChevronDown from '@fortawesome/fontawesome-free-solid/faChevronDown';
6 | import Logo from '../elements/Logo';
7 | import Search from '../elements/Search';
8 |
9 | class Header extends Component {
10 | /**
11 | * Toggle the user menu.
12 | */
13 | toggleUserMenu = event => {
14 | event.preventDefault();
15 | document.body.classList.toggle('user-menu-on');
16 | };
17 |
18 | /**
19 | * Render component.
20 | *
21 | * @return {Component}
22 | */
23 | render() {
24 | const { username, email } = this.props;
25 |
26 | return (
27 |
38 | );
39 | }
40 | }
41 |
42 | export default connect(state => ({ ...state.auth }))(Header);
43 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_user-menu.scss:
--------------------------------------------------------------------------------
1 | //
2 | // User Menu
3 | //
4 |
5 | .app-user-menu {
6 | box-shadow: none;
7 | background: #fff;
8 | border-radius: 4px;
9 | opacity: 0;
10 | padding: 10px 0;
11 | position: fixed;
12 | top: -9999px;
13 | right: 10px;
14 | transform: scale(0);
15 | transform-origin: 80% 0%;
16 | transition: box-shadow 0.4s ease-in-out, transform 0.4s ease-in-out, opacity 0.4s ease-in-out;
17 | width: 150px;
18 | z-index: $user-menu-z-index;
19 |
20 | /*
21 | li:last-child {
22 | border-top: 1px solid $border-color;
23 | margin-top: 5px;
24 | padding-top: 5px;
25 | }
26 | */
27 | ul {
28 | list-style: none;
29 | margin: 0;
30 | padding: 0;
31 | }
32 | a {
33 | color: $text-color;
34 | display: block;
35 | font-size: 14px;
36 | line-height: 30px;
37 | padding: 0 20px;
38 | text-decoration: none;
39 | }
40 | a:hover,
41 | a:focus {
42 | background-color: $bg-color-darken;
43 | color: $text-color-darken;
44 | }
45 | .svg-inline--fa {
46 | display: inline-block;
47 | margin-right: 10px;
48 | text-align: center;
49 | width: 1.2em;
50 | }
51 | }
52 | .user-menu-on .app-user-menu {
53 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
54 | opacity: 1;
55 | top: $admin-header-height - 5px;
56 | transform: scale(1);
57 | }
58 |
--------------------------------------------------------------------------------
/lib/utils/formatting.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | /**
3 | * Format a unified configuration object.
4 | *
5 | * @param {Object} config Mash of config pieces to wrangle.
6 | * @param {Object} config.app Application instance configuration.
7 | * @param {Object} config.contentTypes Content types API instance.
8 | * @param {Object} config.options Options API instance.
9 | */
10 | toPublicConfig: function(config) {
11 | const { app, contentTypes, options } = config;
12 |
13 | const publicConfig = {
14 | adminTitle: app.adminTitle,
15 | contentTypes: contentTypes.get()
16 | // options: options.get() // @TODO
17 | };
18 |
19 | return publicConfig;
20 | },
21 | /**
22 | * Format a string into a slug.
23 | *
24 | * For example:
25 | * `Foo` => `foo`
26 | * `Foo Bar` => `foo-bar`
27 | *
28 | * @param {String} str String to format.
29 | * @return {String} Formatted string.
30 | */
31 | toSlug: function(str) {
32 | return str
33 | .toString()
34 | .toLowerCase()
35 | .replace(/\s+/g, '-') // Replace spaces with -
36 | .replace(/[^\w\-]+/g, '') // Remove all non-word chars
37 | .replace(/\-\-+/g, '-') // Replace multiple - with single -
38 | .replace(/^-+/, '') // Trim - from start of text
39 | .replace(/-+$/, ''); // Trim - from end of text
40 | }
41 | };
42 |
--------------------------------------------------------------------------------
/admin/src/components/elements/NavItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { singularTitle, pluralTitle, getPathBase, getPathItem } from '../../utils/data';
4 |
5 | /**
6 | * Navigation Item
7 | *
8 | * @param {Object} props Component properties.
9 | * @param {String} props.text Optional. Text of parent menu item.
10 | * @param {String} props.endpoint Base URL endpoint of item, like `pages`.
11 | * @param {String} props.currentPath Current pathname from React router.
12 | * @return {Component}
13 | */
14 | const NavItem = props => {
15 | const { endpoint, text, currentPath, hasChildren } = props;
16 | const base = getPathBase(currentPath);
17 | const item = getPathItem(currentPath);
18 |
19 | return (
20 |
21 | {text ? text : pluralTitle(endpoint)}
22 | {hasChildren && (
23 |
24 | -
25 | Manage {pluralTitle(endpoint)}
26 |
27 | -
28 | New {singularTitle(endpoint)}
29 |
30 |
31 | )}
32 |
33 | );
34 | };
35 |
36 | export default NavItem;
37 |
--------------------------------------------------------------------------------
/admin/src/components/fields/TextArea.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | * Textarea
5 | *
6 | * @param {Object} props Component props.
7 | * @param {String} props.value Current value.
8 | * @param {Function} props.onChange Handle input state change.
9 | * @param {String} props.title Optional. Title above input.
10 | * @param {String} props.name Name attribute for input.
11 | * @param {String} props.placeholder Optional. Field placeholder text.
12 | * @param {String} props.help, Optional. Help text below input.
13 | * @param {Boolean} props.hasError Whether currently has error.
14 | * @param {Boolean} props.isRequired Whether field is required.
15 | * @return {Component}
16 | */
17 | const TextArea = props => {
18 | const { value, onChange, title, name, placeholder, help, hasError, isRequired } = props;
19 |
20 | return (
21 |
22 | {title && (
23 |
27 | )}
28 |
36 | {help && {help}}
37 |
38 | );
39 | };
40 |
41 | export default TextArea;
42 |
--------------------------------------------------------------------------------
/admin/src/components/fields/SelectMenu.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | * Select Menu
5 | *
6 | * @param {Object} props Component props.
7 | * @param {String} props.value Current value.
8 | * @param {Function} props.onChange Handle input state change.
9 | * @param {String} props.title Optional. Title above input.
10 | * @param {String} props.name Name attribute for input.
11 | * @param {Array} props.options Options to choose from.
12 | * @param {String} props.placeholder Optional. Field placeholder text.
13 | * @param {String} props.help, Optional. Help text below input.
14 | * @param {Boolean} props.hasError Whether currently has error.
15 | * @param {String} props.className Optional. CSS class(es).
16 | * @return {Component}
17 | */
18 | const SelectMenu = props => {
19 | const { value, onChange, title, name, options, help, hasError, className } = props;
20 |
21 | return (
22 |
23 | {title && }
24 |
33 | {help && {help}}
34 |
35 | );
36 | };
37 |
38 | export default SelectMenu;
39 |
--------------------------------------------------------------------------------
/admin/src/components/layout/UserMenu.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link } from 'react-router-dom';
4 | import LogoutLink from '../elements/LogoutLink';
5 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
6 | import faUser from '@fortawesome/fontawesome-free-solid/faUser';
7 | import faSignOutAlt from '@fortawesome/fontawesome-free-solid/faSignOutAlt';
8 |
9 | /**
10 | * User Menu Component
11 | *
12 | * This is a hidden menu of user options,
13 | * which needs to be toggled into view.
14 | *
15 | * @return {Component}
16 | */
17 | class UserMenu extends Component {
18 | /**
19 | * Closes user menu, if any item is clicked.
20 | */
21 | closeUserMenu = event => {
22 | document.body.classList.remove('user-menu-on');
23 | };
24 |
25 | /**
26 | * Render component.
27 | *
28 | * @return {Component}
29 | */
30 | render() {
31 | return (
32 |
33 |
34 | -
35 |
36 | Edit Profile
37 |
38 |
39 | -
40 |
41 | Log Out
42 |
43 |
44 |
45 |
46 | );
47 | }
48 | }
49 |
50 | export default connect(state => ({ username: state.auth.username }))(UserMenu);
51 |
--------------------------------------------------------------------------------
/admin/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | // Routing
4 | import { Route, Switch, withRouter, Redirect } from 'react-router-dom';
5 | import PrivateRoute from './containers/PrivateRoute';
6 |
7 | // Store
8 | import { connect } from 'react-redux';
9 | import { verify } from './store/auth';
10 | import { loadConfig } from './store/config';
11 |
12 | // Components
13 | import Notice from './components/elements/Notice';
14 | import Admin from './containers/Admin';
15 | import Login from './containers/Login';
16 |
17 | class App extends Component {
18 | /**
19 | * When component mounts, determine if
20 | * user is logged-in and if that token
21 | * is still valid.
22 | */
23 | componentDidMount() {
24 | this.props.verify();
25 | this.props.loadConfig();
26 | }
27 |
28 | /**
29 | * Render component.
30 | *
31 | * @return {Component}
32 | */
33 | render() {
34 | const { isLoading, hasNotice } = this.props;
35 | return (
36 |
37 | {isLoading ? (
38 |
Loading...
39 | ) : (
40 |
41 |
42 |
43 |
44 | )}
45 | {hasNotice &&
}
46 |
47 | );
48 | }
49 | }
50 |
51 | export default withRouter(
52 | connect(
53 | state => ({ isLoading: state.auth.isLoading, hasNotice: state.notice.hasNotice }),
54 | { verify, loadConfig }
55 | )(App)
56 | );
57 |
--------------------------------------------------------------------------------
/admin/src/components/forms/ArticleForm.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { singularTitle, getContentType } from '../../utils/data';
3 | import Fields from '../fields/Fields';
4 | import Input from '../fields/Input';
5 | import Alert from '../elements/Alert';
6 | import Button from '../elements/Button';
7 |
8 | const ArticleForm = props => {
9 | const {
10 | context,
11 | type,
12 | handleSubmit,
13 | handleChange,
14 | handleEditorChange,
15 | title,
16 | inputs,
17 | isSubmitting,
18 | errorOnSubmit
19 | } = props;
20 | const { fields } = getContentType(type);
21 | const btnText =
22 | context === 'new' ? 'Add New ' + singularTitle(type) : 'Update ' + singularTitle(type);
23 |
24 | return (
25 |
48 | );
49 | };
50 |
51 | export default ArticleForm;
52 |
--------------------------------------------------------------------------------
/lib/server/routes/config.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const jwtValidate = require('express-jwt');
3 |
4 | /**
5 | * Creates an Express router instance to get
6 | * configuration data about the application
7 | * instance.
8 | *
9 | * NOTE: None of this data is stored in the
10 | * database!
11 | *
12 | * @param {Object} config Function-specific configuration object.
13 | * @param {String} config.publicConfig Formatted configuration object to send.
14 | * @param {String} config.secret JWT secret for application instance.
15 | * @return {Object} Express.Router() instance.
16 | */
17 | function configRouter(config) {
18 | const router = express.Router();
19 | const { publicConfig, secret } = config;
20 | const { contentTypes, options } = publicConfig;
21 |
22 | router.get('/', function(req, res) {
23 | return res.status(200).send(publicConfig);
24 | });
25 |
26 | router.get('/content-types', function(req, res) {
27 | return res.status(200).send(contentTypes);
28 | });
29 |
30 | router.get('/content-types/:id', function(req, res) {
31 | const find = contentTypes.find(type => type.id === req.params.id);
32 | if (!find) {
33 | return res.status(404).send({ message: `Content type "${req.params.id}" not found.` });
34 | }
35 | return res.status(200).send(find);
36 | });
37 |
38 | // router.get('/option-groups', function(req, res) {}); // @TODO
39 | // router.get('/option-groups/:group', function(req, res) {}); // @TODO
40 |
41 | return router;
42 | }
43 |
44 | /**
45 | * Export factory function.
46 | */
47 | module.exports = configRouter;
48 |
--------------------------------------------------------------------------------
/admin/src/components/fields/RadioGroup.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | * Checkbox Group
5 | *
6 | * @param {Object} props Component props.
7 | * @param {String} props.value Current value.
8 | * @param {Function} props.onChange Handle input state change.
9 | * @param {String} props.title Optional. Title above input.
10 | * @param {String} props.name Name attribute for input.
11 | * @param {Array} props.options Options to choose from.
12 | * @param {String} props.placeholder Optional. Field placeholder text.
13 | * @param {String} props.help, Optional. Help text below input.
14 | * @param {Boolean} props.hasError Whether currently has error.
15 | * @return {Component}
16 | */
17 | const RadioGroup = props => {
18 | const { value, onChange, title, name, options, help, hasError } = props;
19 |
20 | return (
21 |
41 | );
42 | };
43 |
44 | export default RadioGroup;
45 |
--------------------------------------------------------------------------------
/lib/server/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const mongoose = require('mongoose');
3 | const morgan = require('morgan');
4 | const bodyParser = require('body-parser');
5 | const cors = require('cors');
6 | const consolidate = require('consolidate');
7 | const authRouter = require('./routes/auth');
8 | const usersRouter = require('./routes/users');
9 | const createArticleSchema = require('./models/article');
10 | const createSettingSchema = require('./models/setting');
11 | const createUserSchema = require('./models/user');
12 |
13 | /**
14 | * Initialize Express server instance.
15 | */
16 | function createServer(appConfig) {
17 | const server = express();
18 | const { mongoURL } = appConfig;
19 |
20 | // Create schemas.
21 | server.schemas = {
22 | Article: createArticleSchema(),
23 | User: createUserSchema(),
24 | Setting: createSettingSchema()
25 | };
26 |
27 | // Configure templating.
28 | server.engine('hbs', consolidate.handlebars);
29 | server.set('view engine', 'hbs');
30 | server.set('views', __dirname + '/views');
31 |
32 | // Add middleware.
33 | server.use(morgan('dev'));
34 | server.use(bodyParser.json());
35 | server.use(cors());
36 |
37 | // Add non-dynamic, API-specific routes.
38 | server.use('/api/v1/auth', authRouter({ appConfig, schemas: server.schemas }));
39 | server.use('/api/v1/users', usersRouter({ appConfig, schemas: server.schemas }));
40 |
41 | // Connect to database.
42 | mongoose.connect(
43 | mongoURL,
44 | function(err) {
45 | if (err) throw err;
46 | console.log('🔗 Connected to the database.');
47 | }
48 | );
49 |
50 | return server;
51 | }
52 |
53 | /**
54 | * Export server factory function.
55 | */
56 | module.exports = createServer;
57 |
--------------------------------------------------------------------------------
/admin/src/components/fields/Input.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | * Text Input
5 | *
6 | * @param {Object} props Component props.
7 | * @param {String} props.value Current value.
8 | * @param {Function} props.onChange Handle input state change.
9 | * @param {String} props.title Optional. Title above input.
10 | * @param {String} props.name Name attribute for input.
11 | * @param {String} props.type Type attribute for input, defaults to "text".
12 | * @param {String} props.placeholder Optional. Field placeholder text.
13 | * @param {String} props.help, Optional. Help text below input.
14 | * @param {Boolean} props.hasError Whether currently has error.
15 | * @param {Boolean} props.isRequired Whether field is required.
16 | * @param {String} props.className Optional. CSS class(es).
17 | * @return {Component}
18 | */
19 | const Input = props => {
20 | const {
21 | value,
22 | onChange,
23 | title,
24 | name,
25 | type,
26 | placeholder,
27 | help,
28 | hasError,
29 | isRequired,
30 | className
31 | } = props;
32 |
33 | return (
34 |
35 | {title && (
36 |
40 | )}
41 |
50 | {help && {help}}
51 |
52 | );
53 | };
54 |
55 | export default Input;
56 |
--------------------------------------------------------------------------------
/admin/src/containers/View.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Switch, Route, withRouter } from 'react-router';
3 | import { connect } from 'react-redux';
4 | import AdminRoute from './AdminRoute';
5 | import Home from '../components/views/Home';
6 | import NewUser from '../components/views/NewUser';
7 | import EditUser from '../components/views/EditUser';
8 | import ManageUsers from '../components/views/ManageUsers';
9 | import NewArticle from '../components/views/NewArticle';
10 | import EditArticle from '../components/views/EditArticle';
11 | import ManageArticles from '../components/views/ManageArticles';
12 | import NotFound from '../components/views/NotFound';
13 | import Footer from '../components/layout/Footer';
14 |
15 | /**
16 | * Handles routing and the current view.
17 | *
18 | * @return {Component}
19 | */
20 | const View = props => {
21 | const { currentUser } = props;
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | {/*
@TODO*/}
40 |
41 | );
42 | };
43 |
44 | export default withRouter(connect(state => ({ currentUser: state.auth.username }))(View));
45 |
--------------------------------------------------------------------------------
/admin/src/components/elements/Notice.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { removeNotice } from '../../store/notice';
4 | import { timeoutPromise } from '../../../../lib/utils/timing';
5 |
6 | /**
7 | * Global Notification
8 | *
9 | * The store holds a single top-level notification
10 | * for the application.
11 | *
12 | * When populated, this component will get mounted,
13 | * and then unmounted after it's been on the screen
14 | * long enough to be read.
15 | *
16 | * Once seen, the notification will then be removed
17 | * from the store.
18 | */
19 | class Notice extends Component {
20 | /**
21 | * Once the component has mounted, it will
22 | * animate, display, and then remove itself.
23 | */
24 | componentDidMount() {
25 | const elem = document.getElementById('app-notification');
26 |
27 | timeoutPromise(10)
28 | .then(() => {
29 | elem.classList.add('show');
30 | return timeoutPromise(250);
31 | })
32 | .then(() => {
33 | elem.classList.add('apply-check');
34 | return timeoutPromise(2000);
35 | })
36 | .then(() => {
37 | elem.classList.add('hide');
38 | return timeoutPromise(250);
39 | })
40 | .then(() => {
41 | this.props.removeNotice();
42 | });
43 | }
44 |
45 | /**
46 | * Render component.
47 | *
48 | * @return {Component}
49 | */
50 | render() {
51 | const { type, message } = this.props;
52 | return (
53 |
54 |
{message}
55 | {type === 'success' && (
56 |
59 | )}
60 |
61 | );
62 | }
63 | }
64 |
65 | export default connect(
66 | state => ({
67 | ...state.notice
68 | }),
69 | { removeNotice }
70 | )(Notice);
71 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_sidebar.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Sidebar
3 | //
4 |
5 | .app-sidebar {
6 | background: $admin-sidebar-bg-color;
7 | overflow: scroll;
8 | padding: 30px;
9 | position: fixed;
10 | top: $admin-header-height;
11 | left: 0;
12 | bottom: 0;
13 | width: $admin-sidebar-width;
14 | z-index: $sidebar-z-index;
15 | }
16 |
17 | //
18 | // Navigation
19 | //
20 |
21 | .app-nav {
22 | ul {
23 | list-style: none;
24 | margin: 0;
25 | padding: 0;
26 | }
27 | > ul {
28 | margin: 0 0 20px 3px;
29 | }
30 |
31 | // Headings
32 | .nav-title {
33 | color: rgba($text-color-inverse, 0.6);
34 | display: block;
35 | font-size: 13px;
36 | margin: 0 0 10px 0;
37 | }
38 | .nav-title .icon {
39 | margin-right: 8px;
40 | text-align: center;
41 | width: 1.2em;
42 | }
43 |
44 | // All Links
45 | a {
46 | color: rgba($text-color-inverse, 0.75);
47 | font-weight: 500;
48 | }
49 | a:hover {
50 | color: #fff;
51 | }
52 |
53 | // Level 1
54 | > ul > li {
55 | padding: 1px 0;
56 | }
57 | .active-item {
58 | position: relative;
59 | }
60 | .active-item > a {
61 | color: #fff;
62 | font-weight: $fs-font-weight-bold;
63 | }
64 | .active-item:before {
65 | content: '';
66 | background: rgba($primary-color, 0.8);
67 | display: block;
68 | position: absolute;
69 | top: 0;
70 | bottom: 0;
71 | left: -33px;
72 | width: 5px;
73 | }
74 |
75 | // Level 2
76 | .child-menu {
77 | display: none;
78 | padding: 5px 0 0 16px;
79 | }
80 | .child-menu li {
81 | font-size: 14px;
82 | padding: 2px 0;
83 | }
84 | .child-menu a {
85 | border-bottom: 2px solid transparent;
86 | display: inline-block;
87 | padding-bottom: 1px;
88 | transition: border-color 0.2s ease-in-out;
89 | }
90 | .active-item .child-menu {
91 | display: block;
92 | }
93 | .active-child a {
94 | border-bottom-color: rgba($text-color-inverse, 0.25);
95 | color: #fff;
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/lib/server/routes/admin.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const express = require('express');
3 |
4 | /**
5 | * Creates an Express router instance for
6 | * the admin client.
7 | *
8 | * @param {Object} config Function-specific configuration object.
9 | * @param {String} config.appConfig Formatted configuration object to send.
10 | * @param {String} config.publicConfig Formatted configuration object to send.
11 | * @param {String} config.secret JWT secret for application instance.
12 | * @param {Object} config.schemas All currently created schemas.
13 | * @return {Object} Express.Router() instance.
14 | */
15 | function adminRouter(config) {
16 | const router = express.Router();
17 | const { appConfig, publicConfig, schemas } = config;
18 | const { adminTitle } = appConfig;
19 | const { User } = schemas;
20 |
21 | /**
22 | * Check for an admin user.
23 | *
24 | * If no admin user exists yet, redirect
25 | * to the installation page.
26 | */
27 | router.use('/', function(req, res, next) {
28 | User.find({ role: 'admin' }, function(err, users) {
29 | if (err) return res.status(500).send(err);
30 | if (!users.length) return res.redirect('/install');
31 | next();
32 | });
33 | });
34 |
35 | /**
36 | * Serve static admin assets.
37 | */
38 | const staticPath = process.argv.includes('backroadDev')
39 | ? '../../../admin/public'
40 | : '../../../dist/admin'; // prettier-ignore
41 |
42 | router.use('/assets', express.static(path.join(__dirname, staticPath + '/assets')));
43 |
44 | /**
45 | * Render admin panel view.
46 | */
47 | router.get('*', function(req, res) {
48 | const adminData = {
49 | endpoint: 'admin',
50 | ...publicConfig
51 | };
52 |
53 | return res.status(200).render('admin', {
54 | endpoint: 'admin',
55 | title: adminTitle,
56 | data: JSON.stringify(adminData)
57 | });
58 | });
59 |
60 | return router;
61 | }
62 |
63 | /**
64 | * Export factory function.
65 | */
66 | module.exports = adminRouter;
67 |
--------------------------------------------------------------------------------
/lib/server/models/user.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | const Schema = mongoose.Schema;
3 | const bcrypt = require('bcrypt');
4 |
5 | /**
6 | * Creates a Mongoose schema for a
7 | * user in the database.
8 | */
9 | function createUserSchema(config) {
10 | const schema = new Schema(
11 | {
12 | username: {
13 | type: String,
14 | required: true,
15 | unique: true,
16 | lowercase: true
17 | },
18 | password: {
19 | type: String,
20 | required: true
21 | },
22 | role: {
23 | type: String,
24 | enum: ['admin', 'editor'],
25 | required: true,
26 | default: 'editor'
27 | },
28 | email: {
29 | type: String,
30 | required: true,
31 | unique: true
32 | },
33 | first_name: {
34 | type: String,
35 | default: ''
36 | },
37 | last_name: {
38 | type: String,
39 | default: ''
40 | },
41 | bio: {
42 | type: String,
43 | default: ''
44 | }
45 | },
46 | { collection: 'users' }
47 | );
48 |
49 | /**
50 | * Provide internal method for checking
51 | * the password.
52 | */
53 | schema.methods.checkPassword = function(passwordAttempt, callback) {
54 | bcrypt.compare(passwordAttempt, this.password, (err, isMatch) => {
55 | if (err) return callback(err);
56 | callback(null, isMatch);
57 | });
58 | };
59 |
60 | /**
61 | * Provide private user data, when
62 | * authorized.
63 | */
64 | schema.methods.private = function() {
65 | const user = this.toObject();
66 | delete user.password;
67 | return user;
68 | };
69 |
70 | /**
71 | * Provide limited user data, for
72 | * public view.
73 | */
74 | schema.methods.public = function() {
75 | const { username, firstName, lastName, avatar } = this.toObject();
76 | return { username, firstName, lastName, avatar };
77 | };
78 |
79 | return mongoose.model('User', schema);
80 | }
81 |
82 | /**
83 | * Export factory function.
84 | */
85 | module.exports = createUserSchema;
86 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_header.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Header
3 | //
4 |
5 | .app-header {
6 | background-color: #fff;
7 | box-shadow: $shadow-light;
8 | display: flex;
9 | align-items: center;
10 | height: $admin-header-height;
11 | padding: 0 20px 0 0;
12 | position: fixed;
13 | top: 0;
14 | right: 0;
15 | left: 0;
16 | z-index: $header-z-index;
17 | }
18 |
19 | //
20 | // Logo
21 | //
22 |
23 | .app-logo {
24 | background: $admin-logo-bg-color;
25 | display: flex;
26 | align-items: center;
27 | padding: 30px 0 0 30px;
28 | overflow: hidden;
29 | position: relative;
30 | width: $admin-sidebar-width;
31 | height: $admin-header-height;
32 |
33 | .logo {
34 | fill: $text-color-inverse;
35 | width: 90px;
36 | }
37 | span {
38 | color: rgba(255, 255, 255, 0.5);
39 | display: block;
40 | font-size: 10px;
41 | font-weight: 700;
42 | padding: 0 0 0 8px;
43 | }
44 | }
45 |
46 | //
47 | // Search
48 | //
49 |
50 | .app-search {
51 | margin: 0 0 0 18px;
52 | position: relative;
53 |
54 | .svg-inline--fa {
55 | display: block;
56 | position: absolute;
57 | top: 50%;
58 | left: 1em;
59 | margin-top: -7px;
60 | width: 14px;
61 | height: 14px;
62 | z-index: 1;
63 | }
64 | input {
65 | background-color: $fs-body-bg-color;
66 | border-color: transparent;
67 | box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
68 | }
69 | }
70 |
71 | //
72 | // User Menu Toggle
73 | //
74 |
75 | .user-menu-trigger {
76 | color: $text-color;
77 | display: flex;
78 | align-items: center;
79 | margin-left: auto;
80 | position: relative;
81 |
82 | &:hover,
83 | &:focus {
84 | color: $text-color-darken;
85 | }
86 | img {
87 | box-shadow: $shadow-light;
88 | border-radius: 50%;
89 | width: 40px;
90 | height: auto;
91 | }
92 | .svg-inline--fa {
93 | display: block;
94 | margin-right: 10px;
95 | transition: transform 0.2s ease-in-out;
96 | width: 10px;
97 | }
98 | }
99 | .user-menu-on .user-menu-trigger .svg-inline--fa {
100 | transform: rotate(180deg);
101 | }
102 |
--------------------------------------------------------------------------------
/admin/src/components/elements/PrimaryNav.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withRouter } from 'react-router-dom';
3 | import { connect } from 'react-redux';
4 | import { getConfig } from '../../utils/data';
5 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
6 | import faTachometerAlt from '@fortawesome/fontawesome-free-solid/faTachometerAlt';
7 | import faFileAlt from '@fortawesome/fontawesome-free-solid/faFileAlt';
8 | import faCog from '@fortawesome/fontawesome-free-solid/faCog';
9 | import NavItem from './NavItem';
10 |
11 | /**
12 | * Primary Navigation.
13 | *
14 | * @param {Object} props Component properties.
15 | * @param {String} props.location Location from React router.
16 | * @return {Component}
17 | */
18 | const PrimaryNav = props => {
19 | const { location, isAdmin } = props;
20 | const contentTypes = getConfig('contentTypes');
21 |
22 | return (
23 |
24 |
25 | Dashboard
26 |
27 |
30 | {contentTypes && (
31 |
32 |
33 | Content
34 |
35 |
36 | {contentTypes.map(type => (
37 |
43 | ))}
44 |
45 |
46 | )}
47 |
48 | Configure
49 |
50 |
51 | {isAdmin && }
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default withRouter(connect(state => ({ isAdmin: state.auth.role === 'admin' }))(PrimaryNav));
59 |
--------------------------------------------------------------------------------
/lib/server/routes/auth.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const jwtValidate = require('express-jwt');
3 | const jwt = require('jsonwebtoken');
4 |
5 | /**
6 | * Creates an Express router instance for
7 | * the admin client.
8 | *
9 | * @param {Object} config Function-specific configuration object.
10 | * @param {Object} config.appConfig Applicatoin configuration.
11 | * @param {Object} config.schemas All currently created schemas.
12 | * @return {Object} Express.Router() instance.
13 | */
14 | function authRouter(config) {
15 | const router = express.Router();
16 | const { appConfig, schemas } = config;
17 | const { secret } = appConfig;
18 | const { User } = schemas;
19 |
20 | /**
21 | * Handle logging in a user.
22 | */
23 | router.post('/login', function(req, res) {
24 | User.findOne({ username: req.body.username.toLowerCase() }, function(err, user) {
25 | if (err) {
26 | return res.status(500).send(err);
27 | }
28 |
29 | if (!user) {
30 | return res.sendStatus(400);
31 | } else {
32 | user.checkPassword(req.body.password, (err, match) => {
33 | if (err) throw err;
34 |
35 | if (!match) {
36 | return res.sendStatus(401);
37 | }
38 |
39 | const token = jwt.sign(user.toObject(), secret, { expiresIn: '24h' });
40 |
41 | return res.status(201).send({ token, user: user.private() });
42 | });
43 | }
44 | });
45 | });
46 |
47 | /**
48 | * Handle verifying a user that's already
49 | * logged in.
50 | *
51 | * This is just a quick check to help the the
52 | * admin client in displaying itself.
53 | *
54 | * This endpoint does not provide any actual
55 | * authorization for receiving data.
56 | */
57 | router.get('/verify', jwtValidate({ secret }), function(req, res) {
58 | User.findById(req.user._id, function(err, user) {
59 | if (err) {
60 | return res.status(500).send({ success: false, err });
61 | }
62 | if (!user) {
63 | return res.status(400).send({ success: false, message: 'User no longer exists.' });
64 | }
65 | return res
66 | .status(200)
67 | .send({ success: true, message: 'User identity verified.', user: user.private() });
68 | });
69 | });
70 |
71 | return router;
72 | }
73 |
74 | /**
75 | * Export factory function.
76 | */
77 | module.exports = authRouter;
78 |
--------------------------------------------------------------------------------
/lib/content-types.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Content Types API.
3 | *
4 | * @private
5 | *
6 | * @param {Object} config Backstreet configuration.
7 | * @param {Object} config.server Express server instance.
8 | * @param {Object} config.secret Current application JWT secret.
9 | */
10 | function createContentTypes(config) {
11 | const { server, secret } = config;
12 | const types = [];
13 |
14 | /**
15 | * Add a content type.
16 | *
17 | * @public
18 | *
19 | * @param {Object} type Configuration.
20 | * @param {String} type.id Singluar ID (snake-case) like `page` or `blog_post`.
21 | * @param {String} type.name Singluar human-readable document name, like `Page` or `Blog Post`.
22 | * @param {String} type.pluralName Plural human-readable document name, like `Pages` or `Blog Posts`.
23 | * @param {String} type.endpoint Plural url slug, like `pages` or `blog-posts`.
24 | * @param {Array} type.fields Data fields configured for each document.
25 | */
26 | function add(type) {
27 | if (!type.id || !type.name || !type.pluralName || !type.fields) {
28 | return;
29 | }
30 |
31 | if (!type.endpoint) {
32 | type.endpoint = toSlug(contentType.pluralName);
33 | }
34 |
35 | types.push(type);
36 | }
37 |
38 | /**
39 | * Remove a content type.
40 | *
41 | * @public
42 | *
43 | * @param {String|Array} toRemove Content type ID or IDs to remove.
44 | */
45 | function remove(toRemove) {
46 | if (Array.isArray(toRemove)) {
47 | toRemove.forEach(id => {
48 | removeSingle(id);
49 | });
50 | } else if (typeof toRemove === 'string') {
51 | removeSingle(toRemove);
52 | }
53 | }
54 |
55 | /**
56 | * Remove a single content type.
57 | *
58 | * @private
59 | *
60 | * @param {String} id Content type ID to remove.
61 | */
62 | function removeSingle(id) {
63 | const toRemove = types.findIndex(type => {
64 | return type.id === id;
65 | });
66 | types.splice(toRemove, 1);
67 | }
68 |
69 | /**
70 | * Get configuration of content type(s).
71 | *
72 | * @public
73 | *
74 | * @param {String} id Content type id. Omit for all.
75 | * @return {Array|String} All content types or single content type.
76 | */
77 | function get(id = '') {
78 | if (id) {
79 | return types.find(type => type.id === id);
80 | } else {
81 | return types;
82 | }
83 | }
84 |
85 | /**
86 | * Public API.
87 | */
88 | return { add, remove, get };
89 | }
90 |
91 | module.exports = createContentTypes;
92 |
--------------------------------------------------------------------------------
/lib/server/routes/content-types.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const jwtValidate = require('express-jwt');
3 |
4 | /**
5 | * Creates an Express router instance for a
6 | * dynamically added content type.
7 | *
8 | * @param {Object} config Function-specific configuration object.
9 | * @param {String} config.type Configuration for content type.
10 | * @param {Object} config.schemas All currently created schemas.
11 | * @return {Object} Express.Router() instance.
12 | */
13 | function contentTypeRouter(config) {
14 | const router = express.Router();
15 | const { type, schemas } = config;
16 | const { id } = type;
17 | const { Article } = schemas;
18 |
19 | /**
20 | * Handle request to get all documents
21 | * for custom content type.
22 | */
23 | router.get('/', function(req, res) {
24 | const orderBy = req.query.order_by ? req.query.order_by : 'created_at';
25 | const order = req.query.order ? req.query.order : 'desc';
26 | const per_page = req.query.per_page ? Number(req.query.per_page) : 0;
27 | const page = req.query.page ? Number(req.query.page) : 1;
28 |
29 | const query = {
30 | content_type: id
31 | };
32 |
33 | if (req.query.created_by) {
34 | query.created_by = req.query.created_by;
35 | }
36 |
37 | // Query by one of the registered content type's custom
38 | // fields, i.e. `.../?field_name=&field_value=`
39 | if (req.query.field_name && req.query.field_value) {
40 | query[`fields.${req.query.field_name}`] = req.query.field_value;
41 | }
42 |
43 | Article.find(query)
44 | .sort({ [orderBy]: order })
45 | .exec(function(err, articles) {
46 | if (err) {
47 | return res.status(500).send(err);
48 | }
49 |
50 | if (per_page) {
51 | const start = per_page * page - per_page;
52 | const end = per_page * page;
53 | return res.status(200).send(articles.slice(start, end));
54 | }
55 |
56 | return res.status(200).send(articles);
57 | });
58 | });
59 |
60 | /**
61 | * Handle request to get single article
62 | * for custom content type.
63 | *
64 | * NOTE: These GET requests use the slug like
65 | * `my-page` and not the actual _id field.
66 | */
67 | router.get('/:slug', function(req, res) {
68 | Article.findOne({ content_type: id, slug: req.params.slug }, (err, item) => {
69 | if (err) return res.status(500).send(err);
70 | return res.status(200).send(item);
71 | });
72 | });
73 |
74 | return router;
75 | }
76 |
77 | /**
78 | * Export factory function.
79 | */
80 | module.exports = contentTypeRouter;
81 |
--------------------------------------------------------------------------------
/admin/src/components/forms/LoginForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { withRouter } from 'react-router';
3 | import { connect } from 'react-redux';
4 | import { login } from '../../store/auth';
5 | import { addNotice } from '../../store/notice';
6 | import Alert from '../elements/Alert';
7 | import Button from '../elements/Button';
8 | import Input from '../fields/Input';
9 |
10 | /**
11 | * Login Form.
12 | */
13 | class LoginForm extends Component {
14 | /**
15 | * Class constructor.
16 | */
17 | constructor(props) {
18 | super(props);
19 | this.state = {
20 | inputs: {
21 | username: '',
22 | password: ''
23 | },
24 | error: '',
25 | isLoading: false
26 | };
27 | }
28 |
29 | /**
30 | * Handle individual form field
31 | * changes.
32 | */
33 | handleChange = event => {
34 | const { name, value } = event.target;
35 | this.setState(prevState => ({
36 | inputs: {
37 | ...prevState.inputs,
38 | [name]: value
39 | }
40 | }));
41 | };
42 |
43 | /**
44 | * Handle form submission.
45 | */
46 | handleSubmit = event => {
47 | event.preventDefault();
48 |
49 | const { username, password } = this.state.inputs;
50 |
51 | this.setState({ error: '' }); // Clear any previous errors.
52 |
53 | if (!username || !password) {
54 | this.setState({ error: 'Please fill out all fields.' });
55 | return;
56 | }
57 |
58 | this.setState({ isLoading: true });
59 |
60 | this.props.login({ username, password }).then(() => {
61 | const status = this.props.status;
62 |
63 | if (status === 400 || status === 401) {
64 | this.setState({ error: 'Username or password was not correct.', isLoading: false });
65 | return;
66 | }
67 |
68 | this.props.addNotice('Login Successful!', 'success');
69 | this.props.history.push('/');
70 | });
71 | };
72 |
73 | /**
74 | * Render component.
75 | *
76 | * @return {Component}
77 | */
78 | render() {
79 | const { error, isLoading } = this.state;
80 | return (
81 |
89 | );
90 | }
91 | }
92 |
93 | export default withRouter(
94 | connect(
95 | state => ({ status: state.auth.authErrCode.login }),
96 | { login, addNotice }
97 | )(LoginForm)
98 | );
99 |
--------------------------------------------------------------------------------
/install/src/assets/js/app.js:
--------------------------------------------------------------------------------
1 | import '../scss/install.scss';
2 | import axios from 'axios';
3 | import { timeoutPromise } from '../../../../lib/utils/timing';
4 |
5 | const endpoint = window.location.pathname;
6 | const form = document.getElementById('register-form');
7 | const fields = ['username', 'password', 'email'];
8 |
9 | form.addEventListener('submit', function(event) {
10 | event.preventDefault();
11 |
12 | if (!event.target.username.value || !event.target.password.value || !event.target.email.value) {
13 | removeFieldErrors();
14 | fields.forEach(function(field) {
15 | if (!event.target[field].value) {
16 | event.target[field].classList.add('error');
17 | }
18 | });
19 | displayError('Please fill out all fields.');
20 | return;
21 | }
22 |
23 | if (event.target.password.value.length < 8) {
24 | removeFieldErrors();
25 | event.target.password.classList.add('error');
26 | displayError('Your password must be at least 8 characters.');
27 | return;
28 | }
29 |
30 | document.getElementById('btn-submit').classList.add('loading');
31 |
32 | const data = {
33 | username: event.target.username.value,
34 | password: event.target.password.value,
35 | email: event.target.email.value
36 | };
37 |
38 | axios
39 | .post(endpoint, data)
40 | .then(response => {
41 | const notification = document.createElement('div');
42 |
43 | notification.className = 'notification success';
44 |
45 | notification.innerHTML = `
46 | Successfully installed!
47 |
50 | `;
51 |
52 | form.prepend(notification);
53 |
54 | timeoutPromise(10)
55 | .then(() => {
56 | notification.classList.add('show');
57 | return timeoutPromise(250);
58 | })
59 | .then(() => {
60 | notification.classList.add('apply-check');
61 | return timeoutPromise(2000);
62 | })
63 | .then(() => {
64 | window.location = response.data.redirect;
65 | });
66 | })
67 | .catch(error => {
68 | console.log(error.response);
69 | });
70 | });
71 |
72 | function displayError(message) {
73 | const oldAlert = document.getElementById('form-error');
74 |
75 | if (oldAlert) {
76 | oldAlert.remove();
77 | }
78 |
79 | const newAlert = document.createElement('div');
80 |
81 | newAlert.id = 'form-error';
82 | newAlert.className = 'alert alert-danger';
83 | newAlert.innerHTML = `Oops! ${message}`;
84 |
85 | form.prepend(newAlert);
86 | }
87 |
88 | function removeFieldErrors() {
89 | fields.forEach(function(field) {
90 | form[field].classList.remove('error');
91 | });
92 | }
93 |
--------------------------------------------------------------------------------
/lib/server/routes/install.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const express = require('express');
3 | const bcrypt = require('bcrypt');
4 |
5 | /**
6 | * Creates an Express router instance for the
7 | * installation process.
8 | *
9 | * @param {Object} config Function-specific configuration object.
10 | * @param {String} config.appConfig Formatted configuration object to send.
11 | * @param {String} config.publicConfig Formatted configuration object to send.
12 | * @param {String} config.secret JWT secret for application instance.
13 | * @param {Object} config.schemas All currently created schemas.
14 | * @return {Object} Express.Router() instance.
15 | */
16 | function installRouter(config) {
17 | const router = express.Router();
18 | const { schemas } = config;
19 | const { Setting, User } = schemas;
20 |
21 | /**
22 | * Never show the installation screen, if an
23 | * admin user exists.
24 | *
25 | * In the case that an admin role user exists,
26 | * it means the installation process has already
27 | * been done. The route needs to be shutdown,
28 | * or anyone can create a new admin account.
29 | */
30 | router.use('/', function(req, res, next) {
31 | User.find({ role: 'admin' }, function(err, users) {
32 | if (err) return res.status(500).send(err);
33 | if (users.length) return res.redirect('/admin');
34 | next();
35 | });
36 | });
37 |
38 | /**
39 | * Serve static install assets.
40 | */
41 | const staticPath = process.argv.includes('backroadDev')
42 | ? '../../../install/public'
43 | : '../../../dist/public'; // prettier-ignore
44 |
45 | router.use('/assets', express.static(path.join(__dirname, staticPath + '/assets')));
46 |
47 | /**
48 | * Render admin panel view.
49 | */
50 | router.get('*', function(req, res) {
51 | return res.status(200).render('install', {
52 | title: 'Backroad Installation',
53 | endpoint: 'install'
54 | });
55 | });
56 |
57 | /**
58 | * Handle installation.
59 | *
60 | * 1. Save project name to settings.
61 | * 2. Creeate first admin user.
62 | */
63 | router.post('/', function(req, res) {
64 | const password = bcrypt.hashSync(req.body.password, 10);
65 |
66 | const newUser = new User({
67 | username: req.body.username,
68 | password: password,
69 | email: req.body.email,
70 | role: 'admin'
71 | });
72 |
73 | newUser
74 | .save()
75 | .then(user => {
76 | return res.status(201).send({ redirect: '/admin' });
77 | })
78 | .catch(err => {
79 | return res.status(500).send(err);
80 | });
81 | });
82 |
83 | return router;
84 | }
85 |
86 | /**
87 | * Export factory function.
88 | */
89 | module.exports = installRouter;
90 |
--------------------------------------------------------------------------------
/lib/assets/scss/partials/_notifications.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Notification
3 | //
4 |
5 | .notification {
6 | background: #fff;
7 | border-bottom: 5px solid transparent;
8 | box-shadow: 0 0 40px 0 rgba(0, 0, 0, 0.1);
9 | border-radius: 4px;
10 | font-size: 20px;
11 | font-weight: $fs-font-weight-bold;
12 | opacity: 0;
13 | padding: 30px 40px;
14 | position: fixed;
15 | top: 10%;
16 | left: 50%;
17 | text-align: center;
18 | transform: translate(-50%, -10px);
19 | transition: opacity 0.2s ease-in, transform 0.2s ease-in;
20 | width: 250px;
21 | z-index: $notification-z-index;
22 |
23 | &.show {
24 | opacity: 1;
25 | transform: translate(-50%, 0);
26 | }
27 | &.hide {
28 | opacity: 0;
29 | transform: translate(-50%, 10px);
30 | }
31 | &.success {
32 | background-color: rgba($success-color, 0.95);
33 | border-color: darken(adjust-hue($success-color, -10), 50%);
34 | color: $success-color-contrast;
35 |
36 | p {
37 | margin: 0 0 20px 0;
38 | }
39 | }
40 | &.error {
41 | background-color: rgba($danger-color, 0.95);
42 | border-color: darken(adjust-hue($danger-color, -10), 50%);
43 | color: $danger-color-contrast;
44 | }
45 | }
46 |
47 | //
48 | // Inner Success Checkmark
49 | //
50 |
51 | $loader-size: 5em;
52 | $check-height: $loader-size/2;
53 | $check-width: $check-height/2;
54 | $check-left: ($loader-size/6 + $loader-size/12);
55 | $check-thickness: 3px;
56 | $check-color: darken(adjust-hue($success-color, -10), 50%);
57 |
58 | .notification {
59 | .circle {
60 | border: 1px solid rgba(0, 0, 0, 0.2);
61 | border-left-color: $check-color;
62 | border-radius: 50%;
63 | position: relative;
64 | display: inline-block;
65 | width: $loader-size;
66 | height: $loader-size;
67 | vertical-align: top;
68 | }
69 | &.apply-check .circle {
70 | animation: none;
71 | border-color: $check-color;
72 | transition: border 500ms ease-out;
73 | }
74 | .checkmark {
75 | display: none;
76 |
77 | &.draw:after {
78 | animation-duration: 800ms;
79 | animation-timing-function: ease;
80 | animation-name: checkmark;
81 | transform: scaleX(-1) rotate(135deg);
82 | }
83 |
84 | &:after {
85 | opacity: 1;
86 | height: $check-height;
87 | width: $check-width;
88 | transform-origin: left top;
89 | border-right: $check-thickness solid $check-color;
90 | border-top: $check-thickness solid $check-color;
91 | content: '';
92 | left: $check-left;
93 | top: $check-height;
94 | position: absolute;
95 | }
96 | }
97 | &.apply-check .checkmark {
98 | display: block;
99 | }
100 | }
101 |
102 | @keyframes checkmark {
103 | 0% {
104 | height: 0;
105 | width: 0;
106 | opacity: 1;
107 | }
108 | 20% {
109 | height: 0;
110 | width: $check-width;
111 | opacity: 1;
112 | }
113 | 40% {
114 | height: $check-height;
115 | width: $check-width;
116 | opacity: 1;
117 | }
118 | 100% {
119 | height: $check-height;
120 | width: $check-width;
121 | opacity: 1;
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/lib/backroad.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const findRoot = require('find-root');
4 | const chalk = require('chalk');
5 | const jwtValidate = require('express-jwt');
6 | const createServer = require('./server');
7 | const createContentTypes = require('./content-types');
8 | // const createOptions = require('./options'); // @TODO
9 | const adminRouter = require('./server/routes/admin');
10 | const installRouter = require('./server/routes/install');
11 | const configRouter = require('./server/routes/config');
12 | const articlesRouter = require('./server/routes/articles');
13 | const contentTypeRouter = require('./server/routes/content-types');
14 | const { toSlug, toPublicConfig } = require('./utils/formatting');
15 |
16 | /**
17 | * Backroad Headless CMS
18 | *
19 | * This is the main factory function for starting
20 | * a Backroad application instance.
21 | */
22 | function backroad(config) {
23 | const appDefaults = {
24 | adminTitle: 'Backroad Admin',
25 | secret: process.env.SECRET || 'replace_me_with_better_secret',
26 | port: process.env.PORT || 5050,
27 | mongoURL:
28 | process.env.MONGO_URL ||
29 | `mongodb://localhost:27017/${path.basename(path.dirname(process.argv[1]))}`
30 | };
31 |
32 | const appConfig = { ...appDefaults, ...config };
33 |
34 | const projectRoot = findRoot(path.dirname(require.main.filename));
35 |
36 | const server = createServer(appConfig);
37 |
38 | const contentTypes = createContentTypes({ server, secret: appConfig.secret });
39 |
40 | const options = []; // @TODO Options API. -- const options = createOptions()
41 |
42 | /**
43 | * Start the server instance.
44 | *
45 | * @public
46 | */
47 | function start() {
48 | const { secret, port } = appConfig;
49 |
50 | const publicConfig = toPublicConfig({ app: appConfig, contentTypes, options });
51 |
52 | // Add server-side rendered routes.
53 | server.use('/admin', adminRouter({ appConfig, publicConfig, secret, schemas: server.schemas }));
54 | server.use('/install', installRouter({ appConfig, publicConfig, secret, schemas: server.schemas })); // prettier-ignore
55 |
56 | // Add secured configuration endpoints, for admin client.
57 | server.use('/api/v1/config', configRouter({ publicConfig, secret }));
58 |
59 | // Add article endpoints, mainly intended admin client use.
60 | server.use(
61 | '/api/v1/articles',
62 | articlesRouter({ appConfig, types: contentTypes.get(), schemas: server.schemas })
63 | );
64 |
65 | // Public article API. Mask content types within their own endpoints. Read-only.
66 | contentTypes.get().forEach(function(type) {
67 | server.use(`/api/v1/${type.endpoint}`, contentTypeRouter({ type, schemas: server.schemas }));
68 | });
69 |
70 | // Start the Express server.
71 | server.listen(port, function() {
72 | console.log(chalk.green('Server started!'));
73 | console.log(chalk`🚦 Take the backroad via {cyan ${`http://localhost:${port}/admin`}} ...`);
74 | });
75 | }
76 |
77 | /**
78 | * Public API.
79 | */
80 | return {
81 | server,
82 | start,
83 | contentTypes
84 | // options // @TODO
85 | };
86 | }
87 |
88 | module.exports = backroad;
89 |
--------------------------------------------------------------------------------
/admin/src/components/tables/ArticleRow.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { connect } from 'react-redux';
4 | import { addNotice } from '../../store/notice';
5 | import { singularTitle, timestamp, apiUrl } from '../../utils/data';
6 | import authorized from '../../utils/authorized';
7 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
8 | import faTrashAlt from '@fortawesome/fontawesome-free-solid/faTrashAlt';
9 | import faPencilAlt from '@fortawesome/fontawesome-free-solid/faPencilAlt';
10 |
11 | /**
12 | * Single user entry row for the users
13 | * table.
14 | *
15 | * @param {Object} props Component properties.
16 | * @param {String} props.id Article ID, needed for deletion.
17 | * @param {String} props.type Content type endpoint, like `pages` and NOT `page`.
18 | * @param {String} props.title Article title.
19 | * @param {String} props.createdAt Date created
20 | * @param {String} props.createdBy Username of creator.
21 | * @param {String} props.slug Publicly accessible slug.
22 | */
23 | class ArticleRow extends Component {
24 | /**
25 | * Class constructor.
26 | */
27 | constructor(props) {
28 | super(props);
29 | this.state = { isDeleted: false };
30 | }
31 |
32 | /**
33 | * Delete user.
34 | *
35 | * @TODO Confirmation prompt!!!
36 | */
37 | handleDelete = event => {
38 | event.preventDefault();
39 |
40 | const { type, id, addNotice } = this.props;
41 | const typeTitle = singularTitle(type);
42 |
43 | authorized
44 | .delete(apiUrl('delete', type, id))
45 | .then(response => {
46 | this.setState({ isDeleted: true });
47 | addNotice(`${typeTitle} deleted!`, 'success');
48 | })
49 | .catch(err => {
50 | const message = err.response.data.message
51 | ? err.response.data.message
52 | : `${typeTitle} could not be deleted.`;
53 | addNotice(message, 'error');
54 | });
55 | };
56 |
57 | /**
58 | * Render component.
59 | *
60 | * @return {Component}
61 | */
62 | render() {
63 | const { isDeleted } = this.state;
64 | const { type, title, createdAt, createdBy, slug } = this.props;
65 |
66 | if (isDeleted) return null;
67 |
68 | return (
69 |
70 | |
71 |
72 | {title}
73 |
74 | |
75 | {timestamp(createdAt)} |
76 | {createdBy} |
77 |
78 |
79 | -
80 |
81 |
82 |
83 |
84 | -
85 |
86 |
87 |
88 |
89 |
90 | |
91 |
92 | );
93 | }
94 | }
95 |
96 | export default connect(
97 | null,
98 | { addNotice }
99 | )(ArticleRow);
100 |
--------------------------------------------------------------------------------
/admin/src/assets/scss/partials/_tables.scss:
--------------------------------------------------------------------------------
1 | //
2 | // Table Wrap
3 | //
4 |
5 | .manage-users-table,
6 | .manage-articles-table,
7 | .recent-content-table {
8 | background: #fff;
9 | box-shadow: $shadow-dark;
10 |
11 | .table-title {
12 | border-bottom: 1px solid $border-color;
13 | display: flex;
14 | align-items: center;
15 | justify-content: flex-end;
16 | padding: 0 30px;
17 | }
18 | .table-title h1,
19 | .table-title h2 {
20 | font-size: 18px;
21 | line-height: 65px;
22 | margin: 0 auto 0 0;
23 | }
24 | .table-container {
25 | padding: 30px;
26 | }
27 | .table-top {
28 | display: flex;
29 | align-items: center;
30 | justify-content: space-between;
31 | padding: 0 10px 0 0;
32 | }
33 | .table-top select {
34 | background-color: #f8f8f8;
35 | box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
36 | max-width: 200px;
37 | }
38 | .table-top .stats {
39 | color: $text-color-lighten;
40 | display: inline-block;
41 | font-size: 14px;
42 | font-weight: $fs-font-weight-bold;
43 | margin-right: 10px;
44 | }
45 | .table-responsive {
46 | margin: 0;
47 | }
48 | .no-articles {
49 | color: $text-color-lighten;
50 | font-style: italic;
51 | padding: 20px 5px;
52 | }
53 | }
54 |
55 | //
56 | // Tables
57 | //
58 |
59 | table {
60 | thead {
61 | background-color: darken($bg-color, 1%);
62 | }
63 | thead th {
64 | border: none;
65 | color: $text-color;
66 | font-size: 14px;
67 | text-shadow: 1px 1px 0 #fff;
68 | }
69 | td {
70 | border: none;
71 | vertical-align: middle;
72 | }
73 | tr:nth-child(even) {
74 | background: $bg-color;
75 | }
76 | // th:last-child,
77 | // td:last-child {
78 | // text-align: center;
79 | // }
80 | }
81 |
82 | //
83 | // Users Table
84 | //
85 |
86 | .manage-users-table {
87 | .role-label {
88 | &.admin {
89 | background-color: $role-color-admin;
90 | color: darken($role-color-admin, 60%);
91 | }
92 | &.editor {
93 | background-color: $role-color-editor;
94 | color: darken($role-color-editor, 60%);
95 | }
96 | }
97 | }
98 |
99 | //
100 | // Action Menu
101 | //
102 |
103 | .actions-menu {
104 | display: flex;
105 | // justify-content: center;
106 |
107 | li {
108 | margin: 0 2px;
109 | }
110 | a {
111 | border-radius: 50%;
112 | display: block;
113 | background-color: #f6fafc;
114 | color: $text-color;
115 | cursor: pointer;
116 | font-size: 12px;
117 | line-height: 3em;
118 | text-align: center;
119 | transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
120 | width: 3em;
121 | height: 3em;
122 | }
123 | a:hover {
124 | background: $primary-color;
125 | color: #fff;
126 | }
127 | .delete-item:hover {
128 | background-color: darken(adjust-hue($danger-color, -5), 35%);
129 | }
130 | }
131 |
132 | //
133 | // Recent Content Table Wrap
134 | // (extends above)
135 | //
136 |
137 | .recent-content-table {
138 | .table-title .btn {
139 | margin-left: 5px;
140 | }
141 | th:last-child,
142 | td:last-child {
143 | text-align: center;
144 | }
145 | .actions-menu {
146 | justify-content: center;
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backroad",
3 | "version": "1.0.0",
4 | "description": "Headless CMS framework, built on the MERN stack.",
5 | "main": "index.js",
6 | "bin": {
7 | "backroad": "backroad.js"
8 | },
9 | "directories": {
10 | "doc": "doc",
11 | "example": "example",
12 | "lib": "lib"
13 | },
14 | "scripts": {
15 | "start": "concurrently 'npm run server' 'npm run watch:admin' 'npm run watch:install'",
16 | "server": "nodemon ./example/basic/index.js backroadDev",
17 | "watch:admin": "NODE_ENV=development webpack --config ./admin/webpack.config.js --watch",
18 | "build:admin": "NODE_ENV=development webpack --config ./admin/webpack.config.js",
19 | "build:admin:prod": "NODE_ENV=production webpack --config ./admin/webpack.config.js",
20 | "watch:install": "NODE_ENV=development webpack --config ./install/webpack.config.js --watch",
21 | "build:install": "NODE_ENV=development webpack --config ./install/webpack.config.js",
22 | "build:install:prod": "NODE_ENV=production webpack --config ./install/webpack.config.js"
23 | },
24 | "repository": {
25 | "type": "git",
26 | "url": "git+https://github.com/themeblvd/backroad.git"
27 | },
28 | "author": "Jason Bobich",
29 | "license": "GPL-2.0+",
30 | "bugs": {
31 | "url": "https://github.com/themeblvd/backroad/issues"
32 | },
33 | "homepage": "https://github.com/themeblvd/backroad#readme",
34 | "dependencies": {
35 | "@fortawesome/fontawesome": "^1.1.8",
36 | "@fortawesome/fontawesome-free-solid": "^5.0.13",
37 | "@fortawesome/react-fontawesome": "0.0.20",
38 | "@types/mongoose": "^5.0.18",
39 | "axios": "^0.18.0",
40 | "bcrypt": "^2.0.1",
41 | "body-parser": "^1.18.3",
42 | "chalk": "^2.4.1",
43 | "classnames": "^2.2.6",
44 | "consolidate": "^0.15.1",
45 | "cors": "^2.8.4",
46 | "dotenv": "^4.0.0",
47 | "express": "^4.16.3",
48 | "express-jwt": "^5.3.1",
49 | "find-root": "^1.1.0",
50 | "frontstreet": "^1.0.1",
51 | "handlebars": "^4.0.11",
52 | "immutable": "^3.8.2",
53 | "jsonwebtoken": "^8.3.0",
54 | "lodash": "^3.10.1",
55 | "md5": "^2.2.1",
56 | "moment": "^2.22.2",
57 | "mongoose": "^5.1.5",
58 | "mongoose-slug-hero": "^1.1.1",
59 | "morgan": "^1.9.0",
60 | "prismjs": "^1.15.0",
61 | "react-redux": "^5.0.7",
62 | "react-router-dom": "^4.3.1",
63 | "redux": "^4.0.0",
64 | "redux-thunk": "^2.3.0",
65 | "slate": "^0.34.2",
66 | "slate-html-serializer": "^0.6.8",
67 | "slate-react": "^0.12.11"
68 | },
69 | "devDependencies": {
70 | "autoprefixer": "^8.6.3",
71 | "babel-cli": "^6.26.0",
72 | "babel-core": "^6.26.3",
73 | "babel-eslint": "^7.2.3",
74 | "babel-loader": "^7.1.4",
75 | "babel-preset-env": "^1.7.0",
76 | "babel-preset-react": "^6.24.1",
77 | "babel-preset-react-app": "^3.1.1",
78 | "concurrently": "^3.6.1",
79 | "css-loader": "^0.28.11",
80 | "eslint": "^4.19.1",
81 | "eslint-config-react-app": "^2.1.0",
82 | "eslint-loader": "^2.0.0",
83 | "eslint-plugin-flowtype": "^2.49.3",
84 | "eslint-plugin-import": "^2.12.0",
85 | "eslint-plugin-jsx-a11y": "^5.1.1",
86 | "eslint-plugin-react": "^7.9.1",
87 | "file-loader": "^1.1.11",
88 | "mini-css-extract-plugin": "^0.4.0",
89 | "node-sass": "^4.9.0",
90 | "nodemon": "^1.17.5",
91 | "postcss-loader": "^2.1.5",
92 | "react": "^16.4.1",
93 | "react-dom": "^16.4.1",
94 | "sass-loader": "^7.0.3",
95 | "webpack": "^4.12.0",
96 | "webpack-cli": "^3.0.8"
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/admin/src/components/tables/UserRow.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { connect } from 'react-redux';
4 | import { addNotice } from '../../store/notice';
5 | import { apiUrl } from '../../utils/data';
6 | import authorized from '../../utils/authorized';
7 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
8 | import faTrashAlt from '@fortawesome/fontawesome-free-solid/faTrashAlt';
9 | import faUserEdit from '@fortawesome/fontawesome-free-solid/faUserEdit';
10 |
11 | /**
12 | * Single user entry row for the users
13 | * table.
14 | *
15 | * @param {Object} props Component properties.
16 | * @param {String} props.id User ID, needed for deletion.
17 | * @param {String} props.username Username.
18 | * @param {String} props.firstName First name.
19 | * @param {String} props.lastName Last name.
20 | * @param {String} props.email Email address.
21 | * @param {String} props.role User role, `admin` or `editor`.
22 | */
23 | class UserRow extends Component {
24 | /**
25 | * Class constructor.
26 | */
27 | constructor(props) {
28 | super(props);
29 | this.state = { isDeleted: false };
30 | }
31 |
32 | /**
33 | * Delete user.
34 | *
35 | * @TODO Confirmation prompt!!!
36 | */
37 | handleDelete = event => {
38 | event.preventDefault();
39 |
40 | const { id, addNotice } = this.props;
41 |
42 | authorized
43 | .delete(apiUrl('delete', 'users', id))
44 | .then(response => {
45 | this.setState({ isDeleted: true });
46 | addNotice('User deleted!', 'success');
47 | })
48 | .catch(err => {
49 | const message = err.response.data.message
50 | ? err.response.data.message
51 | : 'User could not be deleted.';
52 | addNotice(message, 'error');
53 | });
54 | };
55 |
56 | /**
57 | * Render component.
58 | *
59 | * @return {Component}
60 | */
61 | render() {
62 | const { isDeleted } = this.state;
63 | const { username, firstName, lastName, email, role } = this.props;
64 |
65 | if (isDeleted) {
66 | return null;
67 | }
68 |
69 | var name = '\u2014';
70 |
71 | if (firstName && lastName) {
72 | name = firstName + ' ' + lastName;
73 | } else if (firstName) {
74 | name = firstName;
75 | } else if (lastName) {
76 | name = lastName;
77 | }
78 |
79 | return (
80 |
81 | |
82 |
83 | {username}
84 |
85 | |
86 | {name} |
87 | {email} |
88 |
89 | {role}
90 | |
91 |
92 |
93 | -
94 |
95 |
96 |
97 |
98 | -
99 |
100 |
101 |
102 |
103 |
104 | |
105 |
106 | );
107 | }
108 | }
109 |
110 | export default connect(
111 | null,
112 | { addNotice }
113 | )(UserRow);
114 |
--------------------------------------------------------------------------------
/admin/src/components/fields/Fields.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Input from '../fields/Input';
3 | import TextArea from '../fields/TextArea';
4 | import SelectMenu from '../fields/SelectMenu';
5 | import Checkbox from '../fields/Checkbox';
6 | import RadioGroup from '../fields/RadioGroup';
7 | import MarkdownEditor from '../fields/MarkdownEditor';
8 | import RichTextEditor from '../fields/RichTextEditor';
9 |
10 | /**
11 | * Render set of form fields.
12 | *
13 | * @param {Object} props Component props.
14 | * @param {Object} props.fields Fields to display.
15 | * @param {Object} props.values Field values to match to.
16 | * @param {Function} props.handleChange Single function to handle all field changes.
17 | * @return {Component}
18 | */
19 | const Fields = props => {
20 | const { fields, values, handleChange, handleEditorChange } = props;
21 |
22 | return fields.map(field => {
23 | switch (field.type) {
24 | case 'radio':
25 | return (
26 |
35 | );
36 | case 'select':
37 | return (
38 |
48 | );
49 | case 'checkbox':
50 | return (
51 |
59 | );
60 | case 'textarea':
61 | return (
62 |
72 | );
73 | case 'markdown':
74 | return (
75 |
84 | );
85 | case 'rich-text':
86 | return (
87 |
96 | );
97 | default:
98 | return (
99 |
109 | );
110 | }
111 | });
112 | };
113 |
114 | export default Fields;
115 |
--------------------------------------------------------------------------------
/admin/src/components/forms/UserForm.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Alert from '../elements/Alert';
3 | import Button from '../elements/Button';
4 |
5 | const UserForm = props => {
6 | const { context, handleSubmit, handleChange, inputs, isSubmitting, errorOnSubmit } = props;
7 |
8 | return (
9 |
101 | );
102 | };
103 |
104 | export default UserForm;
105 |
--------------------------------------------------------------------------------
/admin/src/store/auth.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import authorized from '../utils/authorized';
3 |
4 | // Intial State
5 |
6 | const initialState = {
7 | username: '',
8 | role: '',
9 | firstName: '',
10 | lastName: '',
11 | isAuthenticated: false,
12 | authErrCode: {
13 | register: '',
14 | login: ''
15 | },
16 | isLoading: true
17 | };
18 |
19 | // Action Types
20 |
21 | const AUTHENTICATE = 'AUTHENTICATE';
22 |
23 | const AUTH_ERROR = 'AUTH_ERROR';
24 |
25 | const START_LOADING = 'START_LOADING';
26 |
27 | const STOP_LOADING = 'STOP_LOADING';
28 |
29 | const LOGOUT = 'LOGOUT';
30 |
31 | const UPDATE_USER = 'UPDATE_USER';
32 |
33 | // Reducer
34 |
35 | export default function reducer(state = initialState, action) {
36 | switch (action.type) {
37 | case AUTHENTICATE:
38 | return {
39 | ...state,
40 | ...action.user,
41 | isAuthenticated: true,
42 | authErrCode: initialState.authErrCode,
43 | isLoading: false
44 | };
45 |
46 | case AUTH_ERROR:
47 | return {
48 | ...state,
49 | authErrCode: {
50 | ...state.authErrCode,
51 | [action.key]: action.errCode
52 | },
53 | isLoading: false
54 | };
55 |
56 | case START_LOADING:
57 | return {
58 | ...initialState,
59 | isLoading: false
60 | };
61 |
62 | case LOGOUT:
63 | case STOP_LOADING:
64 | return {
65 | ...initialState,
66 | isLoading: false
67 | };
68 |
69 | case UPDATE_USER:
70 | return {
71 | ...state,
72 | ...action.user
73 | };
74 |
75 | default:
76 | return state;
77 | }
78 | }
79 |
80 | // Actions
81 |
82 | function authError(key, errCode) {
83 | return {
84 | type: AUTH_ERROR,
85 | key,
86 | errCode
87 | };
88 | }
89 |
90 | function authenticate(user) {
91 | return {
92 | type: AUTHENTICATE,
93 | user // pass the user for storage in Redux store
94 | };
95 | }
96 |
97 | export function verify() {
98 | return dispatch => {
99 | if (!localStorage.token) {
100 | dispatch({
101 | type: STOP_LOADING
102 | });
103 | return;
104 | }
105 |
106 | authorized
107 | .get('/api/v1/auth/verify')
108 | .then(response => {
109 | const { user } = response.data;
110 | dispatch(authenticate(user));
111 | })
112 | .catch(err => {
113 | dispatch(authError('verify', err.response.status));
114 | });
115 | };
116 | }
117 |
118 | /* @TODO
119 | export function register(userInfo) {
120 | return dispatch => {
121 | return axios
122 | .post('/api/v1/users', userInfo)
123 | .then(response => {
124 | const { token, user } = response.data;
125 | localStorage.token = token;
126 | localStorage.user = JSON.stringify(user);
127 | dispatch(authenticate(user));
128 | })
129 | .catch(err => {
130 | dispatch(authError('register', err.response.status));
131 | });
132 | };
133 | }
134 | */
135 |
136 | export function login(credentials) {
137 | return dispatch => {
138 | return axios
139 | .post('/api/v1/auth/login', credentials)
140 | .then(response => {
141 | const { token, user } = response.data;
142 | localStorage.setItem('token', token);
143 | dispatch(authenticate(user));
144 | })
145 | .catch(err => {
146 | dispatch(authError('login', err.response.status));
147 | });
148 | };
149 | }
150 |
151 | export function logout() {
152 | localStorage.removeItem('token');
153 | return {
154 | type: LOGOUT
155 | };
156 | }
157 |
158 | export function updateUser(user) {
159 | return {
160 | type: UPDATE_USER,
161 | user
162 | };
163 | }
164 |
--------------------------------------------------------------------------------
/lib/assets/scss/utils/_variables.scss:
--------------------------------------------------------------------------------
1 | // ============================================
2 | // Colors
3 | // ============================================
4 |
5 | //
6 | // Branding
7 | //
8 |
9 | $primary-color: #1abc9c !default;
10 | $accent-color: #3498db !default;
11 |
12 | $bg-color: #fafcfd !default;
13 | $bg-color-darken: #edf1f2 !default;
14 | $bg-color-inverse: #2c343f !default;
15 |
16 | //
17 | // Contextual
18 | //
19 |
20 | $success-color: fs-map-deep-get($fs-contextual-colors, success, base) !default;
21 | $success-color-contrast: fs-map-deep-get($fs-contextual-colors, success, contrast) !default;
22 |
23 | $info-color: fs-map-deep-get($fs-contextual-colors, info, base) !default;
24 | $info-color-contrast: fs-map-deep-get($fs-contextual-colors, info, contrast) !default;
25 |
26 | $warning-color: fs-map-deep-get($fs-contextual-colors, warning, base) !default;
27 | $warning-color-contrast: fs-map-deep-get($fs-contextual-colors, warning, contrast) !default;
28 |
29 | $danger-color: fs-map-deep-get($fs-contextual-colors, danger, base) !default;
30 | $danger-color-contrast: fs-map-deep-get($fs-contextual-colors, danger, contrast) !default;
31 |
32 | //
33 | // Text
34 | //
35 |
36 | $text-color: #58666e !default;
37 | $text-color-lighten: #8b99a1 !default;
38 | $text-color-darken: $bg-color-inverse !default;
39 | $text-color-inverse: #fff !default;
40 |
41 | //
42 | // Links
43 | //
44 |
45 | $link-color: $accent-color !default;
46 | $link-color-hover: $primary-color !default;
47 |
48 | //
49 | // Roles
50 | //
51 |
52 | $role-color-admin: #dff0d8 !default;
53 | $role-color-editor: #d9edf7 !default;
54 |
55 | //
56 | // Borders
57 | //
58 |
59 | $border-color: lighten($text-color, 54%) !default;
60 |
61 | //
62 | // Admin UI
63 | //
64 |
65 | $admin-body-bg-color: $bg-color !default;
66 | $admin-logo-bg-color: $bg-color-inverse !default;
67 | $admin-sidebar-bg-color: $bg-color-inverse !default;
68 |
69 | //
70 | // Login UI
71 | //
72 |
73 | $login-body-bg-color: $bg-color-inverse !default;
74 | $login-form-bg-color: $bg-color !default;
75 |
76 | //
77 | // Install UI
78 | //
79 |
80 | $install-body-bg-color: $bg-color-inverse !default;
81 | $install-inner-bg-color: $bg-color-darken;
82 |
83 | //
84 | // Shadows
85 | //
86 |
87 | $shadow-light: 0 1px 1px 0 rgba(0, 0, 0, 0.05) !default;
88 | $shadow-dark: 0 0 10px rgba(100, 100, 100, 0.15) !default;
89 |
90 | // ============================================
91 | // Layout
92 | // ============================================
93 |
94 | //
95 | // Forms
96 | //
97 |
98 | $field-bg-color: #fff;
99 | $field-radius: 4px !default;
100 | $field-shadow: 0 2px 4px rgba(212, 212, 212, 0.9) !default;
101 | $field-shadow-focus: 0 2px 10px rgba(185, 185, 185, 0.9) !default;
102 |
103 | //
104 | // Login UI
105 | //
106 |
107 | //
108 | // Admin UI
109 | //
110 |
111 | $admin-header-height: 65px !default;
112 | $admin-sidebar-width: 260px !default;
113 |
114 | //
115 | // Z-Index
116 | //
117 |
118 | $header-z-index: 100 !default;
119 | $user-menu-z-index: 200 !default;
120 | $sidebar-z-index: 300 !default;
121 | $notification-z-index: 400 !default;
122 |
123 | // ============================================
124 | // Front Street Overrides
125 | // ============================================
126 |
127 | //
128 | // Colors
129 | //
130 |
131 | $fs-branding-colors: (
132 | primary: (
133 | base: $primary-color,
134 | contrast: #fff
135 | ),
136 | secondary: (
137 | base: $accent-color,
138 | contrast: #fff
139 | )
140 | );
141 |
142 | $fs-color-text-base: $text-color;
143 | $fs-color-text-lighten: $text-color-lighten;
144 | $fs-color-text-darken: $text-color-darken;
145 |
146 | $fs-link-color: $link-color;
147 | $fs-link-color-hover: $link-color-hover;
148 |
149 | //
150 | // Forms
151 | //
152 |
153 | $fs-field-bg-color: $field-bg-color;
154 |
--------------------------------------------------------------------------------
/admin/webpack.config.js:
--------------------------------------------------------------------------------
1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
2 | const path = require('path');
3 | const outputPath = process.env.NODE_ENV === 'development' ? 'public' : '../dist/admin'; // prettier-ignore
4 |
5 | module.exports = {
6 | mode: process.env.NODE_ENV,
7 | entry: path.resolve(__dirname, 'src/index.js'),
8 | output: {
9 | path: path.resolve(__dirname, outputPath),
10 | filename: './assets/js/app.js'
11 | },
12 | plugins: [
13 | /*
14 | * Move compiled .scss to an actual .css file in
15 | * the final build.
16 | */
17 | new MiniCssExtractPlugin({
18 | filename: './assets/css/admin.css'
19 | })
20 | ],
21 | module: {
22 | rules: [
23 | /*
24 | * Process JavaScript files and run them
25 | * through babel.
26 | *
27 | * Note: The `babel-preset-react-app` is
28 | * important, to give us public class field
29 | * syntax (i.e. fat arrows and better this
30 | * binding).
31 | */
32 | {
33 | test: /\.js$/,
34 | loader: 'babel-loader',
35 | exclude: /node_modules/,
36 | options: {
37 | babelrc: false,
38 | presets: ['babel-preset-react-app']
39 | }
40 | },
41 | /*
42 | * Process images that were imported from
43 | * JavaScript files.
44 | */
45 | {
46 | test: /\.(svg|png|jpg|gif)$/,
47 | issuer: /\.js/,
48 | use: [
49 | {
50 | loader: 'file-loader',
51 | options: {
52 | outputPath: './assets/img',
53 | name: '[name].[ext]',
54 | publicPath: '/admin/assets/img'
55 | }
56 | }
57 | ]
58 | },
59 | /*
60 | * Process Sass files, using the following
61 | * loaders:
62 | *
63 | * 1. sass-loader: Compiles the Sass into CSS.
64 | * 2. postcss-loader: Applies postcss and autoprefixer
65 | * to CSS.
66 | * 3. css-loader: Gets all the assets from @import
67 | * and url() from within the CSS.
68 | * 4. MiniCssExtractPlugin: Puts compiled CSS
69 | * into a file, configured above.
70 | *
71 | * Note: Imported CSS files will also work.
72 | */
73 | {
74 | test: /\.(scss|css)$/,
75 | use: [
76 | MiniCssExtractPlugin.loader,
77 | 'css-loader',
78 | 'postcss-loader',
79 | {
80 | loader: 'sass-loader',
81 | options: {
82 | outputStyle: process.env.NODE_ENV === 'production' ? 'compressed' : 'expanded'
83 | }
84 | }
85 | ]
86 | },
87 | /*
88 | * Process images that were extracted from
89 | * url() in .scss files, via `css-loader`.
90 | *
91 | * These need a custom public path so that
92 | * the URLs resolve properly from the final
93 | * CSS file.
94 | */
95 | {
96 | test: /\.(svg|png|jpg|gif)$/,
97 | issuer: /\.scss$/,
98 | use: [
99 | {
100 | loader: 'file-loader',
101 | options: {
102 | outputPath: './assets/img',
103 | name: '[name].[ext]',
104 | publicPath: '../img'
105 | }
106 | }
107 | ]
108 | },
109 | /*
110 | * Process font files that were extracted from
111 | * url() in .scss files, via `css-loader`.
112 | */
113 | {
114 | test: /\.(ttf|woff|eot)$/,
115 | use: [
116 | {
117 | loader: 'file-loader',
118 | options: {
119 | outputPath: './assets/font',
120 | name: '[name].[ext]',
121 | publicPath: '../font'
122 | }
123 | }
124 | ]
125 | }
126 | ]
127 | }
128 | };
129 |
--------------------------------------------------------------------------------
/install/webpack.config.js:
--------------------------------------------------------------------------------
1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
2 | const path = require('path');
3 | const outputPath = process.env.NODE_ENV === 'development' ? 'public' : '../dist/install'; // prettier-ignore
4 |
5 | module.exports = {
6 | mode: process.env.NODE_ENV,
7 | entry: path.resolve(__dirname, 'src/assets/js/app.js'),
8 | output: {
9 | path: path.resolve(__dirname, outputPath),
10 | filename: './assets/js/app.js'
11 | },
12 | plugins: [
13 | /*
14 | * Move compiled .scss to an actual .css file in
15 | * the final build.
16 | */
17 | new MiniCssExtractPlugin({
18 | filename: './assets/css/install.css'
19 | })
20 | ],
21 | module: {
22 | rules: [
23 | /*
24 | * Process JavaScript files and run them
25 | * through babel.
26 | *
27 | * Note: The `babel-preset-react-app` is
28 | * important, to give us public class field
29 | * syntax (i.e. fat arrows and better this
30 | * binding).
31 | */
32 | {
33 | test: /\.js$/,
34 | loader: 'babel-loader',
35 | exclude: /node_modules/,
36 | options: {
37 | babelrc: false,
38 | presets: ['babel-preset-react-app']
39 | }
40 | },
41 | /*
42 | * Process images that were imported from
43 | * JavaScript files.
44 | */
45 | {
46 | test: /\.(svg|png|jpg|gif)$/,
47 | issuer: /\.js/,
48 | use: [
49 | {
50 | loader: 'file-loader',
51 | options: {
52 | outputPath: './assets/img',
53 | name: '[name].[ext]',
54 | publicPath: '/assets/img'
55 | }
56 | }
57 | ]
58 | },
59 | /*
60 | * Process Sass files, using the following
61 | * loaders:
62 | *
63 | * 1. sass-loader: Compiles the Sass into CSS.
64 | * 2. postcss-loader: Applies postcss and autoprefixer
65 | * to CSS.
66 | * 3. css-loader: Gets all the assets from @import
67 | * and url() from within the CSS.
68 | * 4. MiniCssExtractPlugin: Puts compiled CSS
69 | * into a file, configured above.
70 | *
71 | * Note: Imported CSS files will also work.
72 | */
73 | {
74 | test: /\.(scss|css)$/,
75 | use: [
76 | MiniCssExtractPlugin.loader,
77 | 'css-loader',
78 | 'postcss-loader',
79 | {
80 | loader: 'sass-loader',
81 | options: {
82 | outputStyle: process.env.NODE_ENV === 'production' ? 'compressed' : 'expanded'
83 | }
84 | }
85 | ]
86 | },
87 | /*
88 | * Process images that were extracted from
89 | * url() in .scss files, via `css-loader`.
90 | *
91 | * These need a custom public path so that
92 | * the URLs resolve properly from the final
93 | * CSS file.
94 | */
95 | {
96 | test: /\.(svg|png|jpg|gif)$/,
97 | issuer: /\.scss$/,
98 | use: [
99 | {
100 | loader: 'file-loader',
101 | options: {
102 | outputPath: './assets/img',
103 | name: '[name].[ext]',
104 | publicPath: '../img'
105 | }
106 | }
107 | ]
108 | },
109 | /*
110 | * Process font files that were extracted from
111 | * url() in .scss files, via `css-loader`.
112 | */
113 | {
114 | test: /\.(ttf|woff|eot)$/,
115 | use: [
116 | {
117 | loader: 'file-loader',
118 | options: {
119 | outputPath: './assets/font',
120 | name: '[name].[ext]',
121 | publicPath: '../font'
122 | }
123 | }
124 | ]
125 | }
126 | ]
127 | }
128 | };
129 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Back Road
2 |
3 | Back Road is a headless CMS framework built with the MERN stack (MongoDB, Express, React, and NodeJS).
4 |
5 | My ultimate goal with this project is to create a framework that helps developers give their clients an admin interface to manage content. That content is then served to a public API which can be consumed by whatever technology the developer wants to use on the front-end.
6 |
7 | ## Current Progress
8 |
9 | Although it's still very much a work in progress, check out the screenshots below that explain its current functionality, to this point.
10 |
11 | Here's the implementation code powering the example used in the screenshots.
12 |
13 | ```
14 | const backroad = require('backroad');
15 | const { post, page, book, movie } = require('./content-types');
16 |
17 | /**
18 | * Create new Back Road application.
19 | */
20 | const app = backroad();
21 |
22 | /**
23 | * Add custom content types.
24 | */
25 | app.contentTypes.add(post);
26 | app.contentTypes.add(page);
27 | app.contentTypes.add(book);
28 | app.contentTypes.add(movie);
29 |
30 | /**
31 | * Run the application!
32 | */
33 | app.start();
34 | ```
35 |
36 | When no `admin` role user exists in the database, starting up the server and visiting the application redirects you to an installation process, where you can create the first admin user.
37 |
38 | 
39 |
40 | After that, the application is installed, and you can log in.
41 |
42 | 
43 |
44 | Upon logging in, you're presented with this dashboard. Currently, this is just an overview all created content from all content types. In the future, hopefully this dashboard can be made more useful.
45 |
46 | 
47 |
48 | All content types need to be added via the API methods of the framework. You can see in this example, we've got Posts, Pages, Books, and Movies. Each content type the developer adds can then be managed separately by the user.
49 |
50 | 
51 |
52 | When the user adds or edits content of a certain type, they're presented with the specific fields set up by the developer for that content type.
53 |
54 | 
55 |
56 | And you can see below that the example content type, Movie, has different fields set up for it.
57 |
58 | The code example above had the content type configuration objects tucked away in another file to keep things clean. But here's an example, of what the code to make the "Movie" content type looks like, in full.
59 |
60 | ```
61 | app.contentTypes.add({
62 | id: 'movie',
63 | name: 'Movie',
64 | endpoint: 'movies',
65 | pluralName: 'Movies',
66 | fields: [
67 | {
68 | id: 'desc',
69 | name: 'Movie Description',
70 | help: 'Enter a description',
71 | type: 'textarea'
72 | },
73 | {
74 | id: 'genre',
75 | name: 'Movie Genre',
76 | type: 'select',
77 | options: ['Action', 'Romance', 'Comedy']
78 | }
79 | ]
80 | });
81 | ```
82 |
83 | And you can see the result, based on the configuration object passed to the `contentTypes` API.
84 |
85 | 
86 |
87 | And of course, user management is going to be an important aspect of any CMS. There are currently two roles a user can have, `admin` or `editor`. At this point, the difference is basically just that an admin can edit users and an editor can't, other than their own profile.
88 |
89 | 
90 |
91 | And here's what it looks like when editing a user.
92 |
93 | 
94 |
--------------------------------------------------------------------------------
/admin/src/components/tables/ManageTable.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Link, withRouter } from 'react-router-dom';
3 | import { connect } from 'react-redux';
4 | import { addNotice } from '../../store/notice';
5 | import { pluralTitle, singularTitle, apiUrl } from '../../utils/data';
6 | import authorized from '../../utils/authorized';
7 |
8 | /**
9 | * Table for managing users and
10 | * documents.
11 | */
12 | class Table extends Component {
13 | /**
14 | * Class constructor.
15 | */
16 | constructor(props) {
17 | super(props);
18 | this.state = this.initialState();
19 | }
20 |
21 | /**
22 | * Initial state.
23 | *
24 | * This is helpful in resetting the state
25 | * in componentWillReceiveProps().
26 | */
27 | initialState = () => {
28 | return {
29 | items: [],
30 | isLoading: true
31 | };
32 | };
33 |
34 | /**
35 | * Load data for the table.
36 | */
37 | loadData = type => {
38 | var url = apiUrl('get', type);
39 |
40 | if (type === 'users') {
41 | url += '?order_by=role&order=asc'; // Show those awesome admins first.
42 | }
43 |
44 | authorized
45 | .get(url)
46 | .then(response => {
47 | this.setState({
48 | items: response.data,
49 | isLoading: false
50 | });
51 | })
52 | .catch(err => {
53 | this.setState({
54 | errorOnLoad: err.response.data.message ? err.response.data.message : 'An error occurred.',
55 | isLoading: false
56 | });
57 | });
58 | };
59 |
60 | /**
61 | * Fetch existing data to populate
62 | * table.
63 | */
64 | componentDidMount() {
65 | const { type } = this.props;
66 | this.loadData(type);
67 | }
68 |
69 | /**
70 | * Reset state and re-fetch data.
71 | *
72 | * Every time we navigate to a route using
73 | * this component, we want to make sure and
74 | * reset everything for the new item.
75 | *
76 | * NOTE: We need to be careful to only update
77 | * when the type or slug changes.
78 | */
79 | componentWillReceiveProps(newProps) {
80 | const { type } = newProps;
81 | if (type !== this.props.type) {
82 | this.setState(this.initialState(), () => {
83 | this.loadData(type);
84 | });
85 | }
86 | }
87 |
88 | /**
89 | * Render component.
90 | *
91 | * @return {Component}
92 | */
93 | render() {
94 | const { items, isLoading } = this.state;
95 | const { type } = this.props;
96 |
97 | if (isLoading) {
98 | return Loading...
;
99 | }
100 |
101 | return (
102 |
103 |
104 |
{pluralTitle(type)}
105 |
106 | Add New {singularTitle(type)}
107 |
108 |
109 |
110 |
111 |
115 |
116 | {items.length} {pluralTitle(type)}
117 |
118 |
119 |
120 | {items.length ? (
121 | this.props.render({
122 | type,
123 | items
124 | })
125 | ) : (
126 |
127 | You haven't created any {pluralTitle(type).toLowerCase()} yet.
128 |
129 | )}
130 |
131 |
132 |
133 | );
134 | }
135 | }
136 |
137 | export default connect(
138 | null,
139 | { addNotice }
140 | )(Table);
141 |
--------------------------------------------------------------------------------
/admin/src/components/tables/RecentContentTable.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import authorized from '../../utils/authorized';
3 | import { Link } from 'react-router-dom';
4 | import {
5 | getConfig,
6 | contentTypeEndpoint,
7 | singularTitle,
8 | pluralTitle,
9 | timestamp,
10 | apiUrl
11 | } from '../../utils/data';
12 | import Alert from '../elements/Alert';
13 | import FontAwesomeIcon from '@fortawesome/react-fontawesome';
14 | import faPencilAlt from '@fortawesome/fontawesome-free-solid/faPencilAlt';
15 |
16 | /**
17 | * Table of recent articles across all
18 | * content types.
19 | */
20 | class RecentContentTable extends Component {
21 | /**
22 | * Class constructor.
23 | */
24 | constructor(props) {
25 | super(props);
26 | this.state = {
27 | isLoading: true,
28 | error: '',
29 | articles: []
30 | };
31 | }
32 |
33 | /**
34 | * Fetch article data from API, when
35 | * component mounts.
36 | */
37 | componentDidMount() {
38 | authorized
39 | .get(apiUrl() + '?per_page=10')
40 | .then(response => {
41 | this.setState({
42 | isLoading: false,
43 | articles: response.data
44 | });
45 | })
46 | .catch(err => {
47 | this.setState({
48 | isLoading: false,
49 | error: "An error occurred. Couldn't fetch recent content."
50 | });
51 | });
52 | }
53 |
54 | /**
55 | * Render component.
56 | *
57 | * @return {Component}
58 | */
59 | render() {
60 | const { error } = this.state;
61 |
62 | if (error) {
63 | return ;
64 | }
65 |
66 | const { articles } = this.state;
67 |
68 | const types = getConfig('contentTypes');
69 |
70 | return (
71 |
72 |
73 |
Recent Content
74 | {types &&
75 | types.map(type => {
76 | return (
77 |
78 | {type.pluralName}
79 |
80 | );
81 | })}
82 |
83 |
84 | {articles.length ? (
85 |
86 |
87 |
88 |
89 | | Title |
90 | Created |
91 | Created By |
92 | Type |
93 | Action |
94 |
95 |
96 |
97 | {articles.map(article => {
98 | const endpoint = contentTypeEndpoint(article.content_type);
99 | const singular = singularTitle(endpoint);
100 | const plural = pluralTitle(endpoint);
101 | const time = timestamp(article.created_at);
102 | return (
103 |
104 | |
105 |
106 | {article.title}
107 |
108 | |
109 | {time} |
110 | {article.created_by} |
111 | {singular} |
112 |
113 |
114 | -
115 |
120 |
121 |
122 |
123 |
124 | |
125 |
126 | );
127 | })}
128 |
129 |
130 |
131 | ) : (
132 |
{"You haven't created any articles yet."}
133 | )}
134 |
135 |
136 | );
137 | }
138 | }
139 |
140 | export default RecentContentTable;
141 |
--------------------------------------------------------------------------------
/admin/src/components/elements/Logo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | * Logo
5 | *
6 | * @return {Component}
7 | */
8 | const Logo = props => {
9 | const { fill } = props;
10 |
11 | return (
12 |
19 | );
20 | };
21 |
22 | export default Logo;
23 |
--------------------------------------------------------------------------------
/lib/server/routes/articles.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const jwtValidate = require('express-jwt');
3 |
4 | /**
5 | * Creates an Express router instance to
6 | * handle articles.
7 | *
8 | * @param {Object} config Function-specific configuration object.
9 | * @param {Object} config.appConfig Applicatoin configuration.
10 | * @param {Object} config.types All registered content types.
11 | * @param {Object} config.schemas All currently created schemas.
12 | * @return {Object} Express.Router() instance.
13 | */
14 | function articlesRouter(config) {
15 | const router = express.Router();
16 | const { appConfig, types, schemas } = config;
17 | const { secret } = appConfig;
18 | const { Article } = schemas;
19 |
20 | /**
21 | * Get articles (always public).
22 | *
23 | * This produces raw articles of all content
24 | * types. While publicly accessible, the /articles
25 | * endpoint is more intended for use by the
26 | * admin client.
27 | *
28 | * So in most cases, when publicly querying
29 | * articles you'll use the content type-specific
30 | * endpoint like `/api/v1//`,
31 | * which will flatten fields into each article object.
32 | */
33 | router.get('/', function(req, res) {
34 | // Get an individual article, by the slug.
35 | if (req.query.slug) {
36 | Article.findOne({ slug: req.query.slug }, function(err, article) {
37 | if (err) {
38 | return res
39 | .status(404)
40 | .send({ message: `Could not find article with slug "${req.query.slug}".` });
41 | }
42 | return res.status(200).send(article);
43 | });
44 | } else {
45 | // Get a set of articles.
46 | const query = {};
47 |
48 | // Only search for registered content types.
49 | query.content_type = { $in: types.map(type => type.id) };
50 |
51 | if (req.query.content_type) {
52 | query.content_type = req.query.content_type;
53 | }
54 |
55 | if (req.query.created_by) {
56 | query.created_by = req.query.created_by;
57 | }
58 |
59 | // Query by one of the registered content type's custom
60 | // fields, i.e. `.../?field_name=&field_value=`
61 | if (req.query.field_name && req.query.field_value) {
62 | query[`fields.${req.query.field_name}`] = req.query.field_value;
63 | }
64 |
65 | const orderBy = req.query.order_by ? req.query.order_by : 'created_at';
66 | const order = req.query.order ? req.query.order : 'desc';
67 | const per_page = req.query.per_page ? Number(req.query.per_page) : 0;
68 | const page = req.query.page ? Number(req.query.page) : 1;
69 |
70 | Article.find(query)
71 | .sort({ [orderBy]: order })
72 | .exec(function(err, articles) {
73 | if (err) {
74 | return res.status(500).send(err);
75 | }
76 |
77 | if (per_page) {
78 | const start = per_page * page - per_page;
79 | const end = per_page * page;
80 | return res.status(200).send(articles.slice(start, end));
81 | }
82 |
83 | return res.status(200).send(articles);
84 | });
85 | }
86 | });
87 |
88 | router.get('/:id', function(req, res) {});
89 |
90 | /**
91 | * Add a new article.
92 | *
93 | * NOTE: Request will fail without content_type
94 | * query parameter (i.e. `/api/v1/articles/?content_type=foo`).
95 | *
96 | * @TODO standard sanitization for req.body.fields, based on content type.
97 | */
98 | router.post('/', jwtValidate({ secret }), function(req, res) {
99 | const currentContentType = req.query.content_type;
100 |
101 | if (!currentContentType) {
102 | return res.status(500).send({
103 | message: 'The "content_type" query parameter is required for creating new articles.'
104 | });
105 | }
106 |
107 | if (!types.find(type => type.id === currentContentType)) {
108 | return res.status(500).send({
109 | message: "The content type doesn't exist or isn't registered."
110 | });
111 | }
112 |
113 | const article = new Article({
114 | ...req.body,
115 | created_by: req.user.username,
116 | content_type: currentContentType
117 | });
118 |
119 | article.save((err, newArticle) => {
120 | if (err) return res.status(500).send(err);
121 | return res.status(201).send(newArticle);
122 | });
123 | });
124 |
125 | /**
126 | * Edit an article.
127 | */
128 | router.put('/:id', jwtValidate({ secret }), function(req, res) {
129 | const data = req.body;
130 |
131 | if (data.content_type) {
132 | delete data.content_type; // Contnet type can't be changed.
133 | }
134 |
135 | Article.findByIdAndUpdate(req.params.id, data, { new: true }, (err, updatedArticle) => {
136 | if (err) return res.status(500).send(err);
137 | return res.status(201).send(updatedArticle);
138 | });
139 | });
140 |
141 | /**
142 | * Delete an article.
143 | */
144 | router.delete('/:id', jwtValidate({ secret }), function(req, res) {
145 | Article.findByIdAndRemove(req.params.id, err => {
146 | if (err) return res.status(500).send(err);
147 | return res.sendStatus(204);
148 | });
149 | });
150 |
151 | return router;
152 | }
153 |
154 | /**
155 | * Export factory function.
156 | */
157 | module.exports = articlesRouter;
158 |
--------------------------------------------------------------------------------
/lib/server/routes/users.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const jwtValidate = require('express-jwt');
3 | const bcrypt = require('bcrypt');
4 |
5 | /**
6 | * Creates an Express router instance to
7 | * handle users.
8 | *
9 | * @param {Object} config Function-specific configuration object.
10 | * @param {Object} config.appConfig Applicatoin configuration.
11 | * @param {Object} config.schemas All currently created schemas.
12 | * @return {Object} Express.Router() instance.
13 | */
14 | function usersRouter(config) {
15 | const router = express.Router();
16 | const { appConfig, schemas } = config;
17 | const { secret } = appConfig;
18 | const { User } = schemas;
19 |
20 | /**
21 | * Quietly verify token.
22 | *
23 | * If token is verified a req.user will get
24 | * added to the current request.
25 | */
26 | router.use(jwtValidate({ secret, credentialsRequired: false }));
27 |
28 | /**
29 | * Get data for all users.
30 | *
31 | * Rules:
32 | * 1. Logged-in admin can get a list of all users
33 | * and data.
34 | * 2. Logged-in non-admin users and non-logged in
35 | * can get a list of all public user info.
36 | *
37 | * Note: If a logged-in non-admin user wants their
38 | * own private info, they need to hit the /users/:username
39 | * endpoint for themselves.
40 | */
41 | router.get('/', function(req, res) {
42 | const orderBy = req.query.order_by ? req.query.order_by : 'created_at';
43 | const order = req.query.order ? req.query.order : 'desc';
44 |
45 | User.find()
46 | .sort({ [orderBy]: order })
47 | .exec(function(err, users) {
48 | if (err) return res.status(500).send(err);
49 |
50 | const userToSend = users.map(user => {
51 | if (req.user && req.user.role === 'admin') {
52 | return user.private();
53 | } else {
54 | return user.public();
55 | }
56 | });
57 |
58 | res.status(200).send(userToSend);
59 | });
60 |
61 | // .then(users => {
62 | // const userToSend = users.map(user => {
63 | // if (req.user && req.user.role === 'admin') {
64 | // return user.private();
65 | // } else {
66 | // return user.public();
67 | // }
68 | // });
69 | // res.status(200).send(userToSend);
70 | // })
71 | // .catch(err => {
72 | // res.status(500).send(err);
73 | // });
74 | });
75 |
76 | /**
77 | * Get data for a user.
78 | *
79 | * There are two sets of user data, one for
80 | * public view and an extended one for private
81 | * view.
82 | *
83 | * Rules:
84 | * 1. Anyone can view public user data for
85 | * any user.
86 | * 2. Logged-in non-admin users can view their
87 | * own private info.
88 | * 3. Logged-in admin user can view private info
89 | * for any user.
90 | */
91 | router.get('/:username', function(req, res) {
92 | User.findOne({ username: req.params.username })
93 | .then(user => {
94 | // If: (1) Not logged-in or (2) Non-admin not
95 | // viewing self, show public user info only.
96 | if (!req.user || (req.user.role !== 'admin' && req.user.username !== user.username)) {
97 | res.status(200).send(user.public());
98 | }
99 | res.status(200).send(user.private());
100 | })
101 | .catch(err => {
102 | res.status(404).send({ message: 'User not found.' });
103 | });
104 | });
105 |
106 | /**
107 | * Add a new user.
108 | *
109 | * Currently, only an administrator can add
110 | * a new user.
111 | */
112 | router.post('/', function(req, res) {
113 | if (!req.user) {
114 | return res.sendStatus(401);
115 | }
116 |
117 | if (req.user.role !== 'admin') {
118 | return res.sendStatus(403);
119 | }
120 |
121 | if (req.body.password) {
122 | req.body.password = bcrypt.hashSync(req.body.password, 10);
123 | }
124 |
125 | const user = new User(req.body);
126 |
127 | user.save((err, newUser) => {
128 | if (err) return res.status(500).send(err);
129 | return res.status(201).send(newUser.private());
130 | });
131 | });
132 |
133 | /**
134 | * Update a user.
135 | *
136 | * Rules:
137 | * 1. Admins can edit any user.
138 | * 2. Non-admins can only edit their user data.
139 | * 3. Non-admins cannot change their role.
140 | * 4. A username can NEVER be changed by anyone.
141 | */
142 | router.put('/:id', function(req, res) {
143 | if (!req.user) {
144 | return res.sendStatus(401);
145 | }
146 |
147 | if (req.user.role !== 'admin' && req.user.username !== req.params.username) {
148 | return res.sendStatus(403);
149 | }
150 |
151 | const data = req.body;
152 |
153 | if (data.username) {
154 | delete data.username; // A username can never be changed.
155 | }
156 |
157 | if (data.role && req.user.role !== 'admin') {
158 | delete data.role; // A non-admin user cannot change their role.
159 | }
160 |
161 | if (data.password) {
162 | data.password = bcrypt.hashSync(data.password, 10);
163 | }
164 |
165 | User.findByIdAndUpdate(req.params.id, data, { new: true }, (err, updatedUser) => {
166 | if (err) return res.status(500).send(err);
167 | return res.status(201).send(updatedUser.private());
168 | });
169 | });
170 |
171 | /**
172 | * Delete a user.
173 | *
174 | * Rules:
175 | * 1. Only admins can delete users.
176 | * 2. A user cannot delete themselves.
177 | */
178 | router.delete('/:id', function(req, res) {
179 | if (!req.user) {
180 | return res.sendStatus(401);
181 | }
182 |
183 | if (req.user.role !== 'admin') {
184 | return res.sendStatus(403);
185 | }
186 |
187 | User.findByIdAndRemove(req.params.id, err => {
188 | if (err) return res.status(500).send(err);
189 | return res.sendStatus(204);
190 | });
191 | });
192 |
193 | return router;
194 | }
195 |
196 | /**
197 | * Export factory function.
198 | */
199 | module.exports = usersRouter;
200 |
--------------------------------------------------------------------------------
/lib/server/views/install.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{title}}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
20 |
21 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/admin/src/components/fields/MarkdownEditor.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Plain from 'slate-plain-serializer';
3 | import { Editor } from 'slate-react';
4 | import Prism from 'prismjs';
5 |
6 | /**
7 | * Add the markdown syntax to Prism.
8 | */
9 | // eslint-disable-next-line
10 | Prism.languages.markdown=Prism.languages.extend("markup",{}),Prism.languages.insertBefore("markdown","prolog",{blockquote:{pattern:/^>(?:[\t ]*>)*/m,alias:"punctuation"},code:[{pattern:/^(?: {4}|\t).+/m,alias:"keyword"},{pattern:/``.+?``|`[^`\n]+`/,alias:"keyword"}],title:[{pattern:/\w+.*(?:\r?\n|\r)(?:==+|--+)/,alias:"important",inside:{punctuation:/==+$|--+$/}},{pattern:/(^\s*)#+.+/m,lookbehind:!0,alias:"important",inside:{punctuation:/^#+|#+$/}}],hr:{pattern:/(^\s*)([*-])([\t ]*\2){2,}(?=\s*$)/m,lookbehind:!0,alias:"punctuation"},list:{pattern:/(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,lookbehind:!0,alias:"punctuation"},"url-reference":{pattern:/!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,inside:{variable:{pattern:/^(!?\[)[^\]]+/,lookbehind:!0},string:/(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,punctuation:/^[\[\]!:]|[<>]/},alias:"url"},bold:{pattern:/(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,lookbehind:!0,inside:{punctuation:/^\*\*|^__|\*\*$|__$/}},italic:{pattern:/(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,lookbehind:!0,inside:{punctuation:/^[*_]|[*_]$/}},url:{pattern:/!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/,inside:{variable:{pattern:/(!?\[)[^\]]+(?=\]$)/,lookbehind:!0},string:{pattern:/"(?:\\.|[^"\\])*"(?=\)$)/}}}}),Prism.languages.markdown.bold.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.italic.inside.url=Prism.util.clone(Prism.languages.markdown.url),Prism.languages.markdown.bold.inside.italic=Prism.util.clone(Prism.languages.markdown.italic),Prism.languages.markdown.italic.inside.bold=Prism.util.clone(Prism.languages.markdown.bold); // prettier-ignore
11 |
12 | /**
13 | * Markdown Editor
14 | */
15 | class MarkdownEditor extends Component {
16 | /**
17 | * Class constructor.
18 | */
19 | constructor(props) {
20 | super(props);
21 | this.state = { value: Plain.deserialize(props.value) };
22 | }
23 |
24 | /**
25 | * Update editor state and value within
26 | * wrapping form.
27 | *
28 | * @param {Change} change
29 | */
30 | onChange = ({ value }) => {
31 | this.setState({ value });
32 | this.props.onChange(this.props.name, Plain.serialize(value));
33 | };
34 |
35 | /**
36 | * Render a Slate mark.
37 | *
38 | * @param {Object} props
39 | * @return {Element}
40 | */
41 | renderMark = props => {
42 | const { children, mark, attributes } = props;
43 |
44 | switch (mark.type) {
45 | case 'bold':
46 | return {children};
47 | case 'code':
48 | return {children};
49 | case 'italic':
50 | return {children};
51 | case 'underlined':
52 | return {children};
53 | case 'title': {
54 | return (
55 |
64 | {children}
65 |
66 | );
67 | }
68 | case 'punctuation': {
69 | return (
70 |
71 | {children}
72 |
73 | );
74 | }
75 | case 'list': {
76 | return (
77 |
85 | {children}
86 |
87 | );
88 | }
89 | case 'hr': {
90 | return (
91 |
99 | {children}
100 |
101 | );
102 | }
103 | }
104 | };
105 |
106 | /**
107 | * Define a decorator for markdown styles.
108 | *
109 | * @param {Node} node
110 | * @return {array}
111 | */
112 | decorateNode(node) {
113 | if (node.object != 'block') return;
114 |
115 | const string = node.text;
116 | const texts = node.getTexts().toArray();
117 | const grammar = Prism.languages.markdown;
118 | const tokens = Prism.tokenize(string, grammar);
119 | const decorations = [];
120 |
121 | let startText = texts.shift();
122 | let endText = startText;
123 | let startOffset = 0;
124 | let endOffset = 0;
125 | let start = 0;
126 |
127 | function getLength(token) {
128 | if (typeof token == 'string') {
129 | return token.length;
130 | } else if (typeof token.content == 'string') {
131 | return token.content.length;
132 | } else {
133 | return token.content.reduce((l, t) => l + getLength(t), 0);
134 | }
135 | }
136 |
137 | for (const token of tokens) {
138 | startText = endText;
139 | startOffset = endOffset;
140 |
141 | const length = getLength(token);
142 | const end = start + length;
143 |
144 | let available = startText.text.length - startOffset;
145 | let remaining = length;
146 |
147 | endOffset = startOffset + remaining;
148 |
149 | while (available < remaining) {
150 | endText = texts.shift();
151 | remaining = length - available;
152 | available = endText.text.length;
153 | endOffset = remaining;
154 | }
155 |
156 | if (typeof token != 'string') {
157 | const range = {
158 | anchorKey: startText.key,
159 | anchorOffset: startOffset,
160 | focusKey: endText.key,
161 | focusOffset: endOffset,
162 | marks: [{ type: token.type }]
163 | };
164 |
165 | decorations.push(range);
166 | }
167 |
168 | start = end;
169 | }
170 |
171 | return decorations;
172 | }
173 |
174 | /**
175 | *
176 | * Render component.
177 | *
178 | * @return {Component} component
179 | */
180 | render() {
181 | const { title, help, isRequired, placeholder } = this.props;
182 |
183 | return (
184 |
185 | {title && (
186 |
190 | )}
191 |
202 | {help &&
{help}}
203 |
204 | );
205 | }
206 | }
207 |
208 | export default MarkdownEditor;
209 |
--------------------------------------------------------------------------------
/admin/src/components/forms/EditForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | // Router
4 | import { withRouter } from 'react-router-dom';
5 |
6 | // Store
7 | import { connect } from 'react-redux';
8 | import { addNotice } from '../../store/notice';
9 | import { updateUser } from '../../store/auth';
10 |
11 | // Utilities
12 | import {
13 | getUserInputs,
14 | getContentTypeInputs,
15 | cleanUserData,
16 | cleanArticleData,
17 | apiUrl
18 | } from '../../utils/data';
19 | import authorized from '../../utils/authorized';
20 | import { timeoutPromise } from '../../../../lib/utils/timing';
21 |
22 | // Components
23 | import Alert from '../elements/Alert';
24 |
25 | /**
26 | * Edit Form
27 | *
28 | * This is an HOC that handles all the data
29 | * when adding or editing users or articles,
30 | * of any content type.
31 | */
32 | class EditForm extends Component {
33 | /**
34 | * Class constructor.
35 | */
36 | constructor(props) {
37 | super(props);
38 | this.state = this.initialState(props);
39 | }
40 |
41 | /**
42 | * Initial state.
43 | *
44 | * This is helpful in resetting the state
45 | * in componentWillReceiveProps().
46 | */
47 | initialState = props => {
48 | const { type, slug } = props;
49 |
50 | return {
51 | context: slug === 'new' ? 'new' : 'edit',
52 | errorOnLoad: '',
53 | errorOnSubmit: '',
54 | isLoading: true,
55 | isSubmitting: false,
56 | title: '', // Not used with users.
57 | _id: '',
58 | inputs: type === 'users' ? getUserInputs() : getContentTypeInputs(type)
59 | };
60 | };
61 |
62 | /**
63 | * Load data for the item.
64 | *
65 | * NOTE: When the context of the form is
66 | * adding a new item, there's no data to
67 | * retrieve for the form.
68 | */
69 | loadData = (type, slug) => {
70 | if (slug === 'new') {
71 | this.setState({
72 | context: 'new',
73 | isLoading: false
74 | });
75 | return;
76 | }
77 |
78 | authorized
79 | .get(apiUrl('get', type, slug))
80 | .then(response => {
81 | // If we're editing an article data stored in fields,
82 | // and with user's it's stored directly in response.data.
83 | const inputs = response.data.fields ? response.data.fields : response.data;
84 |
85 | this.setState({
86 | _id: response.data._id,
87 | title: response.data.title && response.data.title,
88 | inputs: { ...this.state.inputs, ...inputs },
89 | isLoading: false
90 | });
91 | })
92 | .catch(err => {
93 | this.setState({
94 | errorOnLoad: err.response.data.message ? err.response.data.message : 'An error occurred.',
95 | isLoading: false
96 | });
97 | });
98 | };
99 |
100 | /**
101 | * Fetch existing data for item, that
102 | * the user will be editing.
103 | */
104 | componentDidMount() {
105 | const { type, slug } = this.props;
106 | this.loadData(type, slug);
107 | }
108 |
109 | /**
110 | * Reset state and re-fetch data.
111 | *
112 | * Every time we navigate to a route using
113 | * this component, we want to make sure and
114 | * reset everything for the new item.
115 | *
116 | * NOTE: We need to be careful to only update
117 | * when the type or slug changes.
118 | */
119 | componentWillReceiveProps(newProps) {
120 | const { type, slug } = newProps;
121 | if (type !== this.props.type || slug !== this.props.slug) {
122 | this.setState(this.initialState(newProps), () => {
123 | this.loadData(type, slug);
124 | });
125 | }
126 | }
127 |
128 | /**
129 | * Handle individual form field
130 | * changes.
131 | */
132 | handleChange = event => {
133 | const { name, value, type, checked } = event.target;
134 |
135 | if (name === 'title') {
136 | this.setState({ title: value });
137 | return;
138 | }
139 |
140 | this.setState(prevState => ({
141 | inputs: {
142 | ...prevState.inputs,
143 | [name]: type === 'checkbox' ? checked : value
144 | }
145 | }));
146 | };
147 |
148 | /**
149 | * Handle changes from Slate content
150 | * editors.
151 | */
152 | handleEditorChange = (name, value) => {
153 | this.setState(prevState => ({
154 | inputs: {
155 | ...prevState.inputs,
156 | [name]: value
157 | }
158 | }));
159 | };
160 |
161 | /**
162 | * Handle form submission.
163 | */
164 | handleSubmit = event => {
165 | event.preventDefault();
166 |
167 | const { type, history, updateUser, currentUserId } = this.props;
168 | const { context, _id, inputs } = this.state;
169 |
170 | const data =
171 | type === 'users'
172 | ? cleanUserData({ ...this.state }, context)
173 | : cleanArticleData({ ...this.state }, context);
174 |
175 | if (typeof data === 'string') {
176 | // String means error message from cleanUserData().
177 | this.setState({
178 | errorOnSubmit: data,
179 | isSubmitting: false
180 | });
181 | return;
182 | }
183 |
184 | this.setState({ isSubmitting: true });
185 |
186 | const options = {
187 | method: context === 'new' ? 'post' : 'put',
188 | url: context === 'new' ? apiUrl('post', type) : apiUrl('put', type, _id),
189 | data
190 | };
191 |
192 | authorized(options)
193 | .then(response => {
194 | return timeoutPromise(1000, response); // Force at least 1 second delay in response.
195 | })
196 | .then(response => {
197 | // Add global notice.
198 | this.props.addNotice('Saved!', 'success');
199 |
200 | if (context === 'new') {
201 | if (type === 'users') {
202 | // When saving a new user, forward back to Manage
203 | // Users page.
204 | history.push('/users');
205 | } else {
206 | // When saving a new article, forward to the
207 | // Edit view of it, which will now exist.
208 | history.push(`/${type}/${response.data.slug}`);
209 | }
210 | } else {
211 | // When saving an existing user or article,
212 | // adjust state and stay in the current component
213 | // for further editing.
214 | this.setState(prevState => ({
215 | ...prevState,
216 | errorOnSubmit: '',
217 | isSubmitting: false,
218 | inputs: {
219 | ...prevState.inputs,
220 | password: '',
221 | password_confirm: ''
222 | }
223 | }));
224 |
225 | // When a user saves their own data, also dispatch
226 | // to the store.
227 | if (type === 'users' && currentUserId === _id) {
228 | updateUser(response.data);
229 | }
230 | }
231 | })
232 | .catch(err => {
233 | console.dir(err);
234 | var errorMessage = 'An error occurred.';
235 | if (err.response && err.response.data && err.response.data.message) {
236 | errorMessage = err.response.data.message;
237 | }
238 | this.setState({
239 | errorOnSubmit: errorMessage,
240 | isSubmitting: false
241 | });
242 | });
243 | };
244 |
245 | /**
246 | * Render component.
247 | *
248 | * @return {Component}
249 | */
250 | render() {
251 | const { isLoading, errorOnLoad } = this.state;
252 |
253 | if (isLoading) {
254 | return Loading...
;
255 | }
256 |
257 | if (errorOnLoad) {
258 | return ;
259 | }
260 |
261 | return this.props.render({
262 | title: this.state.title, // Only used with articles.
263 | context: this.state.context,
264 | type: this.props.type,
265 | handleSubmit: this.handleSubmit,
266 | handleChange: this.handleChange,
267 | handleEditorChange: this.handleEditorChange,
268 | inputs: this.state.inputs,
269 | isSubmitting: this.state.isSubmitting,
270 | errorOnSubmit: this.state.errorOnSubmit
271 | });
272 | }
273 | }
274 |
275 | export default withRouter(
276 | connect(
277 | state => ({ currentUserId: state.auth._id }),
278 | { addNotice, updateUser }
279 | )(EditForm)
280 | );
281 |
--------------------------------------------------------------------------------
/admin/src/utils/data.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 |
3 | /**
4 | * Get Backroad configuration.
5 | *
6 | * @param {String} part Optional. Specific part of config.
7 | * @return {Object}
8 | */
9 | export function getConfig(part) {
10 | const backroad = JSON.parse(window.backroad);
11 | if (part) return backroad[part];
12 | return backroad;
13 | }
14 |
15 | /**
16 | * Get a specific content type's configuration
17 | * object.
18 | *
19 | * @param {String} endpoint Content type endpoings like `pages`.
20 | * @return {Object} Found content type.
21 | */
22 | export function getContentType(endpoint) {
23 | const types = getConfig('contentTypes');
24 | return types.find(type => type.endpoint === endpoint);
25 | }
26 |
27 | /**
28 | * Check to see if a content type ID is
29 | * valid or not.
30 | *
31 | * @param {String} endpoint Content type endpoings like `pages`.
32 | * @return {Boolean} Whether valid.
33 | */
34 | export function isValidContentType(endpoint) {
35 | return getContentType(endpoint) ? true : false;
36 | }
37 |
38 | /**
39 | * Get content type endpoint.
40 | *
41 | * Most components throughout the app tend
42 | * to have the endpoint available for a content
43 | * type and so most of our helper functions
44 | * here use that.
45 | *
46 | * So this function is helpful for the few
47 | * scenarios where you have the actual ID of
48 | * the content type and you want access to
49 | * these other helper functions.
50 | *
51 | * @param {String} id Content type ID.
52 | * @return {String} Content type endpoint.
53 | */
54 | export function contentTypeEndpoint(id) {
55 | const contentTypes = getConfig('contentTypes');
56 | return contentTypes.find(type => type.id === id).endpoint;
57 | }
58 |
59 | /**
60 | * Get the singular title for a content type.
61 | *
62 | * @param {String} endpoint Content type endpoings like `pages`.
63 | * @return {String} Singular title.
64 | */
65 | export function singularTitle(endpoint) {
66 | if (endpoint === 'users') return 'User';
67 | const type = getContentType(endpoint);
68 | if (type && type.name) return type.name;
69 | return '';
70 | }
71 |
72 | /**
73 | * Get the singular title for a content type.
74 | *
75 | * @param {String} endpoint Content type endpoings like `pages`.
76 | * @return {String} Plural title.
77 | */
78 | export function pluralTitle(endpoint) {
79 | if (endpoint === 'users') return 'Users';
80 | const type = getContentType(endpoint);
81 | if (type && type.pluralName) return type.pluralName;
82 | return '';
83 | }
84 |
85 | /**
86 | * Format the create_at attribute from an article.
87 | *
88 | * @param {String} time Timestamp from Mongoose.
89 | * @return {String} Formatted time.
90 | */
91 | export function timestamp(time) {
92 | var timestamp = moment(new Date(time))
93 | .startOf('hour')
94 | .fromNow();
95 |
96 | return timestamp.charAt(0).toUpperCase() + timestamp.substr(1); // Capitalize 1st letter, if not number.
97 | }
98 |
99 | /**
100 | * Get initial inputs for editing
101 | * a user.
102 | *
103 | * @param {String} endpoint Content type endpoings like `pages`.
104 | * @return {Object}
105 | */
106 | export function getContentTypeInputs(endpoint) {
107 | const type = getContentType(endpoint);
108 | const inputs = {};
109 | type.fields.forEach(function(field) {
110 | const { id, type, options } = field;
111 | inputs[field.id] = field.default ? field.default : fieldDefault(type, options);
112 | });
113 | return inputs;
114 | }
115 |
116 | /**
117 | * Get a default, blank starting value,
118 | * depending on the type of field.
119 | *
120 | * @param {String} type Field type, like `text`, `textarea`, 'checkbox', etc.
121 | * @param {Array} options Options when relevant, like with a