diff --git a/package-lock.json b/package-lock.json index 0c0708a..02db5ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4879,9 +4879,9 @@ } }, "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", "dev": true }, "lodash._baseclone": { @@ -5247,6 +5247,12 @@ "minimist": "0.0.8" } }, + "moment": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.23.0.tgz", + "integrity": "sha512-3IE39bHVqFbWWaPOMHZF98Q9c3LDKGTmypMiTM2QygGXXElkFWIH7GxfmlwmY2vwa+wmNsoYZmG2iusf1ZjJoA==", + "dev": true + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", diff --git a/package.json b/package.json index 60cecf5..91bb44e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,10 @@ "extract-text-webpack-plugin": "^3.0.2", "hammerjs": "^2.0.8", "html-webpack-plugin": "^2.28.0", + "lodash": "^4.17.11", + "moment": "^2.23.0", "node-sass": "^4.5.3", + "prop-types": "^15.6.2", "react": "^16.6.3", "regenerator-runtime": "^0.12.0", "relaks": "^1.1.7", diff --git a/src/main.js b/src/main.js index 39162d5..ba2ebf9 100644 --- a/src/main.js +++ b/src/main.js @@ -13,6 +13,10 @@ if (typeof(window) === 'object') { async function initialize(evt) { // create data source let host = `${location.protocol}//${location.host}`; + if (process.env.NODE_ENV !== 'production' && process.env.WEBPACK_DEV_SERVER) { + // use hardcoded URL when we're running in dev-server + host = 'http://localhost:8000'; + } let dataSource = new WordpressDataSource({ baseURL: `${host}/wp-json`, }); @@ -31,10 +35,13 @@ if (typeof(window) === 'object') { if (!appContainer) { throw new Error('Unable to find app element in DOM'); } - //let ssrElement = createElement(FrontEnd, { dataSource, routeManager, ssr: 'hydrate' }); - //let seeds = await harvest(ssrElement, { seeds: true }); - //Relaks.set('seeds', seeds); - //hydrate(ssrElement, appContainer); + // expect SSR unless we're running in dev-server + if (!(process.env.NODE_ENV !== 'production' && process.env.WEBPACK_DEV_SERVER)) { + let ssrElement = createElement(FrontEnd, { dataSource, routeManager, ssr: 'hydrate' }); + let seeds = await harvest(ssrElement, { seeds: true }); + Relaks.set('seeds', seeds); + hydrate(ssrElement, appContainer); + } let appElement = createElement(FrontEnd, { dataSource, routeManager }); render(appElement, appContainer); diff --git a/src/style.scss b/src/style.scss index d46f609..9169f20 100644 --- a/src/style.scss +++ b/src/style.scss @@ -18,12 +18,6 @@ BODY { A { &:link, &:visited { text-decoration: none; - color: #000; - } - - &:hover { - text-decoration: underline; - color: #f11; } } @@ -35,6 +29,30 @@ A { bottom: 0; background-color: #66023c; overflow: hidden; + color: #cccccc; + + A { + color: #cccccc; + } + + .archive { + LI { + margin-top: 0.1em; + margin-bottom: 0.1em; + } + + .year { + cursor: pointer; + } + + .months { + overflow: hidden; + + &.collapsed { + height: 0; + } + } + } } .top-nav { @@ -44,6 +62,10 @@ A { width: calc(100% - 18em); background-color: #990000; // prevent visual glitch in Android + A { + color: #cccccc; + } + .title-bar { display: flex; align-items: center; diff --git a/src/widgets/side-nav.jsx b/src/widgets/side-nav.jsx index 32a776d..50fc90c 100644 --- a/src/widgets/side-nav.jsx +++ b/src/widgets/side-nav.jsx @@ -1,5 +1,105 @@ +import _ from 'lodash'; +import Moment from 'moment'; import React, { PureComponent } from 'react'; import { AsyncComponent } from 'relaks'; +import { Route } from 'routing'; +import WordPress from 'wordpress'; + +class SideNav extends AsyncComponent { + static displayName = 'SideNav'; + + constructor(props) { + super(props); + let { route } = this.props; + let selectedYear; + if (route.params.month) { + selectedYear = parseInt(route.params.month.substr(0, 4)); + } else { + selectedYear = Moment().year(); + } + this.state = { + selectedYear + }; + } + + async renderAsync(meanwhile) { + let { wp, route } = this.props; + let { selectedYear } = this.state; + let props = { + route, + selectedYear, + onYearSelect: this.handleYearSelect, + }; + meanwhile.delay(50, 50); + meanwhile.show(); + props.categories = await wp.fetchList('/wp/v2/categories/'); + meanwhile.show(); + + // get the latest post and the earliest post + let latestPosts = await wp.fetchList('/wp/v2/posts/'); + let latestPost = _.first(latestPosts); + let earliestPosts = await wp.fetchList(`/wp/v2/posts/?order=asc&per_page=1`) + let earliestPost = _.first(earliestPosts); + + // build the archive tree + props.archive = []; + if (latestPost && earliestPost) { + let lastMonthEnd = Moment(latestPost.date_gmt).endOf('month'); + let firstMonthStart = Moment(earliestPost.date_gmt).startOf('month'); + let currentYearEntry; + // loop from the last month to the first + let e = lastMonthEnd.clone(); + let s = e.clone().startOf('month'); + while (s >= firstMonthStart) { + let year = s.year(); + let month = s.month() + 1; + if (!currentYearEntry || currentYearEntry.year !== year) { + // start a new year + currentYearEntry = { + year, + label: s.format('YYYY'), + months: [] + }; + props.archive.push(currentYearEntry); + } + let monthEntry = { + month, + label: s.format('MMMM'), + slug: s.format('YYYY-MM'), + post: undefined, + start: s.clone(), + end: e.clone(), + }; + currentYearEntry.months.push(monthEntry); + + e.subtract(1, 'month'); + s.subtract(1, 'month'); + } + meanwhile.show(); + + // load the posts of the selected year + for (let yearEntry of props.archive) { + if (yearEntry.year === selectedYear) { + for (let monthEntry of yearEntry.months) { + let before = monthEntry.end.toISOString(); + let after = monthEntry.start.toISOString(); + let url = `/wp/v2/posts/?before=${before}&after=${after}`; + monthEntry.posts = await wp.fetchList(url); + + // force prop change + props.archive = _.clone(props.archive); + meanwhile.show(); + } + } + } + } + return ; + } + + handleYearSelect = (evt) => { + this.setState({ selectedYear: evt.year }); + } +} class SideNavSync extends PureComponent { static displayName = 'SideNavSync'; @@ -7,13 +107,128 @@ class SideNavSync extends PureComponent { render() { return (
- Side-Nav + {this.renderCategories()} + {this.renderArchive()}
) } + + renderCategories() { + let { categories } = this.props; + if (!categories) { + return null; + } + // don't show categories with no post + categories = _.filter(categories, 'count'); + // list category with more posts first + categories = _.orderBy(categories, [ 'count', 'name' ], [ 'desc', 'asc' ]); + return ( +
    + { + categories.map((category, i) => { + return this.renderCategory(category, i); + }) + } +
+ ) + } + + renderCategory(category, i) { + let name = _.get(category, 'name', ''); + let description = _.get(category, 'description', ''); + let slug = _.get(category, 'slug', ''); + let url = ''; + return ( +
  • + {name} +
  • + ); + } + + renderArchive() { + let { archive } = this.props; + if (!archive) { + return null; + } + return ( +
      + { + archive.map((entry, i) => { + return this.renderYear(entry, i); + }) + } +
    + ); + } + + renderYear(yearEntry, i) { + let { selectedYear } = this.props; + let labelClass = 'year'; + let listClass = 'months'; + if (yearEntry.year === selectedYear) { + labelClass += ' selected'; + } else { + listClass += ' collapsed'; + } + return ( +
  • + + {yearEntry.label} + +
      + { + yearEntry.months.map((entry, i) => { + return this.renderMonth(entry, i); + }) + } +
    +
  • + ) + } + + renderMonth(monthEntry, i) { + let url; + if (!_.isEmpty(monthEntry.posts) || _.isUndefined(monthEntry.posts)) { + url = '#'; + } + return ( +
  • + {monthEntry.label} +
  • + ); + } + + handleYearClick = (evt) => { + let { onYearSelect } = this.props; + let year = parseInt(evt.currentTarget.getAttribute('data-year')); + if (onYearSelect) { + onYearSelect({ + type: 'yearselect', + target: this, + year, + }); + } + } +} + +if (process.env.NODE_ENV !== 'production') { + const PropTypes = require('prop-types'); + + SideNav.propTypes = { + wp: PropTypes.instanceOf(WordPress).isRequired, + route: PropTypes.instanceOf(Route).isRequired, + }; + SideNavSync.propTypes = { + categories: PropTypes.arrayOf(PropTypes.object), + archive: PropTypes.arrayOf(PropTypes.object), + selectedYear: PropTypes.number, + route: PropTypes.instanceOf(Route), + onYearSelect: PropTypes.func, + }; } export { - SideNavSync as default, + SideNav as default, + SideNav, SideNavSync, }; diff --git a/src/widgets/top-nav.jsx b/src/widgets/top-nav.jsx index 4fc6324..48945cd 100644 --- a/src/widgets/top-nav.jsx +++ b/src/widgets/top-nav.jsx @@ -1,12 +1,32 @@ +import _ from 'lodash'; import React, { PureComponent } from 'react'; import { AsyncComponent } from 'relaks'; +import { Route } from 'routing'; +import WordPress from 'wordpress'; -class TopBarSync extends PureComponent { +class TopNav extends AsyncComponent { + static displayName = 'TopNav'; + + async renderAsync(meanwhile) { + let { wp, route } = this.props; + let props = { + route, + }; + meanwhile.show(); + props.system = await wp.fetchOne('/'); + meanwhile.show(); + props.pages = await wp.fetchList('/wp/v2/pages/'); + return ; + } +} + +class TopNavSync extends PureComponent { static displayName = 'TopNavSync'; render() { + let { onMouseOver, onMouseOut } = this.props; return ( -
    +
    {this.renderTitleBar()} {this.renderPageLinkBar()} {this.renderSearchBar()} @@ -15,34 +35,52 @@ class TopBarSync extends PureComponent { } renderTitleBar() { + let { system } = this.props; + let name = _.get(system, 'name', ''); + let description = _.get(system, 'description', ''); return (
    -
    +
    - Romanes eunt domus + {name}
    ); } renderPageLinkBar() { + let { pages } = this.props; + pages = _.sortBy(pages, 'menu_order'); return (
    -
    - Hello -
    -
    - World -
    + { + pages.map((page, i) => { + return this.renderPageLinkButton(page, i); + }) + } +
    + ); + } + + renderPageLinkButton(page, i) { + let { route } = this.props; + let title = _.get(page, 'title.rendered'); + let slug = _.get(page, 'slug'); + let url = ''; + return ( + ); } renderSearchBar() { + let { route } = this.props; + let search = _.get(route.params, 'search', ''); return (
    - +
    @@ -50,7 +88,28 @@ class TopBarSync extends PureComponent { } } -export { - TopBarSync as default, - TopBarSync, +TopNavSync.defaultProps = { + system: {}, + pages: [], + search: '', +}; + +if (process.env.NODE_ENV !== 'production') { + const PropTypes = require('prop-types'); + + TopNav.propTypes = { + wp: PropTypes.instanceOf(WordPress).isRequired, + route: PropTypes.instanceOf(Route).isRequired, + }; + TopNavSync.propTypes = { + system: PropTypes.object, + pages: PropTypes.arrayOf(PropTypes.object), + route: PropTypes.instanceOf(Route).isRequired, + }; +} + +export { + TopNav as default, + TopNav, + TopNavSync, }; diff --git a/webpack.config.js b/webpack.config.js index d3e2995..45a24e9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -57,26 +57,9 @@ var clientConfig = { analyzerMode: (event === 'build') ? 'static' : 'disabled', reportFilename: `report.html`, }), - new HtmlWebpackPlugin({ - template: Path.resolve(`./src/index.html`), - filename: Path.resolve(`./server/www/index.html`), - }), new ExtractTextPlugin("styles.css"), ], devtool: (event === 'build') ? false : 'inline-source-map', - devServer: { - inline: true, - historyApiFallback: { - rewrites: [ - { - from: /.*/, - to: function(context) { - return context.parsedUrl.pathname.replace(/.*\/(.*)$/, '/$1'); - } - } - ] - } - } }; var serverConfig = { @@ -102,7 +85,39 @@ var serverConfig = { devtool: clientConfig.devtool, }; -var configs = module.exports = clientConfig; //[ clientConfig, serverConfig ]; +var configs = module.exports = [ clientConfig, serverConfig ]; + +var isDevServer = process.argv.find((arg) => { + return arg.includes('webpack-dev-server') ; +}); +if (isDevServer) { + // remove server config + configs.pop(); + // need HTML page + clientConfig.plugins.push(new HtmlWebpackPlugin({ + template: Path.resolve(`./src/index.html`), + filename: Path.resolve(`./server/www/index.html`), + })); + // set constant + var constants = { + 'process.env.WEBPACK_DEV_SERVER': 'true', + }; + clientConfig.plugins.unshift(new DefinePlugin(constants)); + // config dev-server to support client-side routing + clientConfig.devServer = { + inline: true, + historyApiFallback: { + rewrites: [ + { + from: /.*/, + to: function(context) { + return context.parsedUrl.pathname.replace(/.*\/(.*)$/, '/$1'); + } + } + ] + } + }; +} var constants = {}; if (event === 'build') { @@ -110,14 +125,13 @@ if (event === 'build') { configs.forEach((config) => { // set NODE_ENV to production - var plugins = config.plugins; var constants = { 'process.env.NODE_ENV': '"production"', }; - plugins.unshift(new DefinePlugin(constants)); + config.plugins.unshift(new DefinePlugin(constants)); // use Uglify to remove dead-code - plugins.unshift(new UglifyJSPlugin({ + config.plugins.unshift(new UglifyJSPlugin({ uglifyOptions: { compress: { drop_console: true,