├── .gitignore ├── README.md ├── spa ├── .babelrc ├── .eslintrc.json ├── .gitignore ├── client │ ├── .eslintrc.json │ ├── api │ │ ├── config.js │ │ ├── posts │ │ │ ├── getAll.js │ │ │ └── getTop5.js │ │ └── session │ │ │ ├── create.js │ │ │ ├── destroy.js │ │ │ └── local.js │ ├── assets │ │ ├── fonts │ │ │ ├── latoLatinLight.ttf │ │ │ ├── latoLatinLight.woff │ │ │ ├── latoLatinLight.woff2 │ │ │ ├── nexaExtraboldWebfont.ttf │ │ │ ├── nexaExtraboldWebfont.woff │ │ │ ├── nexaExtraboldWebfont.woff2 │ │ │ ├── nexaHeavyWebfont.ttf │ │ │ ├── nexaHeavyWebfont.woff │ │ │ └── nexaHeavyWebfont.woff2 │ │ ├── img │ │ │ └── peerigonLogoMint.svg │ │ └── public │ │ │ ├── favicon.ico │ │ │ ├── manifest.json │ │ │ ├── postImage1.jpg │ │ │ ├── postImage2.jpg │ │ │ ├── postImage3.jpg │ │ │ ├── postImage4.jpg │ │ │ └── userImage.jpg │ ├── components │ │ ├── about │ │ │ ├── about.css.js │ │ │ └── about.js │ │ ├── allPosts │ │ │ └── allPosts.js │ │ ├── app │ │ │ ├── app.css.js │ │ │ └── app.js │ │ ├── form │ │ │ └── form.js │ │ ├── formFeedback │ │ │ ├── formFeedback.css.js │ │ │ └── formFeedback.js │ │ ├── header │ │ │ ├── common.js │ │ │ ├── header.css.js │ │ │ ├── header.js │ │ │ ├── link.css.js │ │ │ ├── logo │ │ │ │ ├── logo.css.js │ │ │ │ └── logo.js │ │ │ ├── nav │ │ │ │ ├── nav.css.js │ │ │ │ └── nav.js │ │ │ └── session │ │ │ │ ├── anonymous │ │ │ │ └── anonymous.js │ │ │ │ ├── personal │ │ │ │ ├── personal.css.js │ │ │ │ └── personal.js │ │ │ │ └── session.js │ │ ├── loading │ │ │ ├── loading.css.js │ │ │ └── loading.js │ │ ├── loginForm │ │ │ ├── loginForm.css.js │ │ │ ├── loginForm.js │ │ │ └── loginFormValidators.js │ │ ├── modal │ │ │ ├── modal.css.js │ │ │ └── modal.js │ │ ├── notFound │ │ │ └── notFound.js │ │ ├── posts │ │ │ ├── post │ │ │ │ ├── post.css.js │ │ │ │ └── post.js │ │ │ ├── posts.css.js │ │ │ └── posts.js │ │ ├── router │ │ │ ├── goBack.js │ │ │ ├── link.js │ │ │ ├── routePlaceholder.js │ │ │ ├── router.js │ │ │ └── util │ │ │ │ ├── routeToHref.js │ │ │ │ ├── routingContext.js │ │ │ │ └── trigger.js │ │ ├── top5 │ │ │ └── top5.js │ │ └── util │ │ │ ├── placeholder.js │ │ │ ├── withContext.js │ │ │ └── withTitle.js │ ├── index.html │ ├── index.js │ ├── init │ │ ├── globals.js │ │ ├── render.js │ │ ├── serviceWorker.js │ │ └── styles.js │ ├── routes.js │ ├── styles │ │ ├── a11y.js │ │ ├── block │ │ │ ├── inputSubmit.js │ │ │ ├── inputText.js │ │ │ └── sheet.js │ │ ├── borders.js │ │ ├── calc.js │ │ ├── colors.js │ │ ├── gradients.js │ │ ├── layout.js │ │ ├── paddings.js │ │ ├── pre │ │ │ ├── body.css │ │ │ └── reset.css │ │ ├── scales.js │ │ ├── timing.js │ │ ├── type │ │ │ ├── fonts │ │ │ │ ├── latoLight.css │ │ │ │ ├── nexaHeavy.css │ │ │ │ └── nexaXbold.css │ │ │ ├── latoLight.js │ │ │ ├── nexaHeavy.js │ │ │ └── nexaXBold.js │ │ ├── typoSizes.js │ │ └── zIndex.js │ └── util │ │ ├── asyncContext.js │ │ ├── asyncPropsCache.js │ │ ├── createEventHandler.js │ │ ├── generateId.js │ │ ├── htmlEntities.js │ │ ├── mapToObject.js │ │ └── useDefault.js ├── config │ ├── server.json │ └── webpack.config.babel.js ├── package-lock.json ├── package.json ├── server │ ├── api.js │ ├── dummyData │ │ ├── generate.js │ │ ├── posts.json │ │ └── users.json │ ├── env.js │ └── index.js └── tools │ └── webpack │ ├── InlinePreStylesPlugin.js │ └── exportCssLoader.js └── universal ├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .vscode └── launch.json ├── app ├── .eslintrc.json ├── assets │ ├── fonts │ │ ├── latoLatinLight.ttf │ │ ├── latoLatinLight.woff │ │ ├── latoLatinLight.woff2 │ │ ├── nexaExtraboldWebfont.ttf │ │ ├── nexaExtraboldWebfont.woff │ │ ├── nexaExtraboldWebfont.woff2 │ │ ├── nexaHeavyWebfont.ttf │ │ ├── nexaHeavyWebfont.woff │ │ └── nexaHeavyWebfont.woff2 │ ├── img │ │ └── peerigonLogoMint.svg │ └── public │ │ ├── favicon.ico │ │ ├── manifest.json │ │ ├── postImage1.jpg │ │ ├── postImage2.jpg │ │ ├── postImage3.jpg │ │ ├── postImage4.jpg │ │ └── userImage.jpg ├── client │ ├── .eslintrc.json │ ├── captureFormSubmit.js │ ├── captureHistoryPop.js │ ├── captureLinkClick.js │ ├── index.js │ └── preloadChunkEntries.js ├── components │ ├── about │ │ ├── about.css.js │ │ └── about.js │ ├── allPosts │ │ └── allPosts.js │ ├── app │ │ ├── app.css.js │ │ └── app.js │ ├── chunks │ │ ├── chunks.js │ │ └── defineChunkEntry.js │ ├── document │ │ └── document.js │ ├── error │ │ ├── error.css.js │ │ └── error.js │ ├── form │ │ ├── defineForm.js │ │ └── form.js │ ├── formFeedback │ │ ├── formFeedback.css.js │ │ └── formFeedback.js │ ├── header │ │ ├── common.js │ │ ├── header.css.js │ │ ├── header.js │ │ ├── link.css.js │ │ ├── logo │ │ │ ├── logo.css.js │ │ │ └── logo.js │ │ ├── nav │ │ │ ├── nav.css.js │ │ │ └── nav.js │ │ └── session │ │ │ ├── anonymous │ │ │ └── anonymous.js │ │ │ ├── personal │ │ │ ├── personal.css.js │ │ │ └── personal.js │ │ │ └── session.js │ ├── loading │ │ ├── loading.css.js │ │ └── loading.js │ ├── loginForm │ │ ├── index.js │ │ ├── loginForm.css.js │ │ ├── loginForm.js │ │ └── loginFormValidators.js │ ├── modal │ │ ├── modal.css.js │ │ ├── modal.js │ │ ├── modalLink.js │ │ └── modalTrigger.js │ ├── placeholder │ │ └── placeholder.js │ ├── posts │ │ ├── post │ │ │ ├── post.css.js │ │ │ └── post.js │ │ ├── posts.css.js │ │ └── posts.js │ ├── router │ │ ├── errors │ │ │ └── methodNotAllowed.js │ │ ├── link.js │ │ ├── routePlaceholder.js │ │ ├── router.js │ │ └── util │ │ │ ├── changeRoute.js │ │ │ ├── createRouter.js │ │ │ ├── enterRoute.js │ │ │ ├── resolveRouteAndParams.js │ │ │ └── sanitizeRequest.js │ ├── session │ │ └── session.js │ ├── store │ │ └── store.js │ ├── top5 │ │ └── top5.js │ └── util │ │ ├── attrSelector.js │ │ ├── defineComponent.js │ │ ├── hookIntoEvent.js │ │ ├── renderChild.js │ │ └── withContext.js ├── contexts.js ├── createApp.js ├── effects │ ├── .eslintrc.json │ ├── api │ │ ├── api.browser.js │ │ ├── api.node.js │ │ ├── index.js │ │ ├── posts │ │ │ ├── getAll.js │ │ │ └── getTop5.js │ │ └── session │ │ │ ├── create.js │ │ │ └── destroy.js │ ├── csrf │ │ ├── csrf.browser.js │ │ ├── csrf.node.js │ │ └── index.js │ ├── document │ │ ├── document.browser.js │ │ ├── document.node.js │ │ └── index.js │ ├── history │ │ ├── history.browser.js │ │ ├── history.node.js │ │ └── index.js │ └── session │ │ ├── index.js │ │ ├── session.browser.js │ │ └── session.node.js ├── env.js ├── routes │ ├── about │ │ ├── about.js │ │ └── index.js │ ├── allPosts │ │ ├── allPosts.js │ │ └── index.js │ ├── error │ │ ├── error.js │ │ └── index.js │ ├── index.js │ ├── notFound │ │ ├── index.js │ │ └── notFound.js │ ├── session │ │ ├── index.js │ │ └── session.js │ └── top5 │ │ ├── index.js │ │ └── top5.js ├── server │ ├── assetTags.js │ ├── createRenderStream.js │ ├── index.js │ ├── paths.js │ ├── preloadAllChunkEntries.js │ └── renderApp.js ├── store │ ├── createReducer.js │ ├── createStore.js │ ├── defineState.js │ ├── effectMiddleware.js │ ├── enhanceStore.js │ └── thunkMiddleware.js ├── styles │ ├── a11y.js │ ├── block │ │ ├── inputSubmit.js │ │ ├── inputText.js │ │ └── sheet.js │ ├── borders.js │ ├── calc.js │ ├── colors.js │ ├── gradients.js │ ├── layout.js │ ├── paddings.js │ ├── reset.js │ ├── scales.js │ ├── timing.js │ ├── type │ │ ├── latoLight.js │ │ ├── nexaHeavy.js │ │ └── nexaXBold.js │ ├── typoSizes.js │ └── zIndex.js └── util │ ├── addObjectKeys.js │ ├── filterProps.js │ ├── has.js │ ├── htmlEntities.js │ ├── renderUrl.js │ └── statusCodes.js ├── config ├── server.json └── webpack.config.babel.js ├── package-lock.json ├── package.json ├── server ├── api.js ├── config.js ├── dummyData │ ├── generate.js │ ├── posts.json │ └── users.json ├── env.js └── index.js └── tools └── webpack ├── ResolveEffectPlugin.js ├── WriteAssetsJsonPlugin.js └── exportCssLoader.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Evaluation 2 | 3 | ## Platform recommendations 4 | 5 | The setup has been verified to run correctly with: 6 | 7 | - macOS Sierra 10.12.6 8 | - Node.js v8.1.3 9 | - NPM v5.0.3 10 | - Chrome Browser 60.0.3112.90 11 | 12 | **Please note: Only NPM 5 will install the exact same dependencies as specified in the `package-lock.json`. Older NPM versions might install different versions than the verified test setup.** 13 | 14 | ## SPA implementation 15 | 16 | ### Installation 17 | 18 | 1. Inside the `spa` folder, execute `npm install` to install all dependencies 19 | 2. Run `npm start` 20 | 3. Open [http://localhost:3000](http://localhost:3000) 21 | 4. Use "jhnns" and "password" as login credentials 22 | 23 | ## Universal implementation 24 | 25 | ### Installation 26 | 27 | 1. Inside the `universal` folder, execute `npm install` to install all dependencies 28 | 2. Run `npm start` 29 | 3. Open [http://localhost:3000](http://localhost:3000) 30 | 4. Use "jhnns" and "password" as login credentials 31 | 32 | ### Note on folder structure 33 | 34 | - `app` contains all the code that is going to be processed by the bundler. 35 | - `app/client` represents the client app entry. 36 | - `app/server` represents the server app entry. 37 | - `app/components` contains components, views and states. They have been coalesced by features. The folder name "components" refers here to its original meaning "self-contained part of a larger entity". 38 | - `app/routes` contains route handlers. 39 | - `server` is the server entry. 40 | - `server/api` roughly equals the server data layer. -------------------------------------------------------------------------------- /spa/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "presets": [ 5 | [ 6 | "env", 7 | { 8 | "targets": "current" 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | "dynamic-import-node" 14 | ], 15 | "sourceMaps": "inline" 16 | }, 17 | "browser": { 18 | "presets": [ 19 | [ 20 | "env", 21 | { 22 | "targets": { 23 | "browsers": [ 24 | "last 2 versions" 25 | ] 26 | }, 27 | "modules": false 28 | } 29 | ] 30 | ], 31 | "plugins": [ 32 | [ 33 | "transform-react-jsx", 34 | { 35 | "pragma": "h" 36 | } 37 | ], 38 | "transform-react-constant-elements" 39 | ] 40 | } 41 | }, 42 | "plugins": [ 43 | "syntax-dynamic-import", 44 | "transform-runtime", 45 | "transform-object-rest-spread" 46 | ], 47 | "retainLines": true 48 | } -------------------------------------------------------------------------------- /spa/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "peerigon" 4 | ], 5 | "env": { 6 | "node": true 7 | }, 8 | "root": true, 9 | "rules": { 10 | "no-mixed-operators": "off" 11 | } 12 | } -------------------------------------------------------------------------------- /spa/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | /public 61 | /dist -------------------------------------------------------------------------------- /spa/client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "jsx-a11y" 4 | ], 5 | "extends": [ 6 | "peerigon/react", 7 | "plugin:jsx-a11y/recommended" 8 | ], 9 | "env": { 10 | "browser": true 11 | }, 12 | "settings": { 13 | "import/resolver": { 14 | "webpack": { 15 | "config": "config/webpack.config.babel.js" 16 | } 17 | } 18 | }, 19 | "rules": { 20 | // Since we have no store, each React component may host its own state. 21 | // So, we don't use stateless functions in this SPA example to avoid unnecessary refactoring. 22 | // Currently, they provide not performance benefit anyway. 23 | "react/prefer-stateless-function": "off", 24 | "class-methods-use-this": "off", 25 | "react/no-unknown-property": [ 26 | "error", 27 | { 28 | "ignore": [ 29 | "class" 30 | ] 31 | } 32 | ] 33 | } 34 | } -------------------------------------------------------------------------------- /spa/client/api/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | root: "/api", 3 | }; 4 | -------------------------------------------------------------------------------- /spa/client/api/posts/getAll.js: -------------------------------------------------------------------------------- 1 | import fetch from "unfetch"; 2 | import config from "../config"; 3 | 4 | export default function getAll() { 5 | return fetch(`${ config.root }/posts`) 6 | .then(res => res.json()) 7 | .then(res => res.items); 8 | } 9 | -------------------------------------------------------------------------------- /spa/client/api/posts/getTop5.js: -------------------------------------------------------------------------------- 1 | import fetch from "unfetch"; 2 | import config from "../config"; 3 | 4 | export default function getTop5() { 5 | return fetch(`${ config.root }/posts?limit=5&sortBy=starred`) 6 | .then(res => res.json()) 7 | .then(res => res.items); 8 | } 9 | -------------------------------------------------------------------------------- /spa/client/api/session/create.js: -------------------------------------------------------------------------------- 1 | import fetch from "unfetch"; 2 | import { update as updateLocalSession } from "./local"; 3 | import config from "../config"; 4 | 5 | const defaultOptions = { 6 | method: "POST", 7 | headers: { 8 | "Content-Type": "application/json", 9 | }, 10 | }; 11 | 12 | export default function create(payload) { 13 | return fetch(`${ config.root }/session`, { 14 | ...defaultOptions, 15 | body: JSON.stringify(payload), 16 | }) 17 | .then(res => res.json()) 18 | .then(res => { 19 | if (res.status === "success") { 20 | updateLocalSession(res.data); 21 | 22 | return res.data; 23 | } 24 | 25 | throw new Error(res.message); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /spa/client/api/session/destroy.js: -------------------------------------------------------------------------------- 1 | import { update as updateLocalSession } from "./local"; 2 | 3 | export default function destroy() { 4 | updateLocalSession({ 5 | token: null, 6 | user: null, 7 | }); 8 | // Only a hard reload ensures that all personal data is cleared 9 | window.location.reload(); 10 | } 11 | -------------------------------------------------------------------------------- /spa/client/api/session/local.js: -------------------------------------------------------------------------------- 1 | const localStorageNamespace = "session"; 2 | const session = deserialize() || { 3 | user: null, 4 | token: null, 5 | }; 6 | 7 | function serialize() { 8 | localStorage.setItem(localStorageNamespace, JSON.stringify(session)); 9 | } 10 | 11 | function deserialize() { 12 | const sessionString = localStorage.getItem(localStorageNamespace); 13 | 14 | if (sessionString === null) { 15 | return null; 16 | } 17 | 18 | return JSON.parse(sessionString); 19 | } 20 | 21 | export function update(newValues) { 22 | Object.keys(newValues).forEach(key => { 23 | session[key] = newValues[key]; 24 | }); 25 | serialize(); 26 | } 27 | 28 | export default session; 29 | -------------------------------------------------------------------------------- /spa/client/assets/fonts/latoLatinLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/latoLatinLight.ttf -------------------------------------------------------------------------------- /spa/client/assets/fonts/latoLatinLight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/latoLatinLight.woff -------------------------------------------------------------------------------- /spa/client/assets/fonts/latoLatinLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/latoLatinLight.woff2 -------------------------------------------------------------------------------- /spa/client/assets/fonts/nexaExtraboldWebfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/nexaExtraboldWebfont.ttf -------------------------------------------------------------------------------- /spa/client/assets/fonts/nexaExtraboldWebfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/nexaExtraboldWebfont.woff -------------------------------------------------------------------------------- /spa/client/assets/fonts/nexaExtraboldWebfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/nexaExtraboldWebfont.woff2 -------------------------------------------------------------------------------- /spa/client/assets/fonts/nexaHeavyWebfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/nexaHeavyWebfont.ttf -------------------------------------------------------------------------------- /spa/client/assets/fonts/nexaHeavyWebfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/nexaHeavyWebfont.woff -------------------------------------------------------------------------------- /spa/client/assets/fonts/nexaHeavyWebfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/fonts/nexaHeavyWebfont.woff2 -------------------------------------------------------------------------------- /spa/client/assets/img/peerigonLogoMint.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /spa/client/assets/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/public/favicon.ico -------------------------------------------------------------------------------- /spa/client/assets/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /spa/client/assets/public/postImage1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/public/postImage1.jpg -------------------------------------------------------------------------------- /spa/client/assets/public/postImage2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/public/postImage2.jpg -------------------------------------------------------------------------------- /spa/client/assets/public/postImage3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/public/postImage3.jpg -------------------------------------------------------------------------------- /spa/client/assets/public/postImage4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/public/postImage4.jpg -------------------------------------------------------------------------------- /spa/client/assets/public/userImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/spa/client/assets/public/userImage.jpg -------------------------------------------------------------------------------- /spa/client/components/about/about.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { rem } from "../../styles/scales"; 3 | import sheet from "../../styles/block/sheet"; 4 | import { 5 | regularFontSize, 6 | regularLineHeight, 7 | regularMaxWidth, 8 | headlineFontSize, 9 | headlineLineHeight, 10 | } from "../../styles/typoSizes"; 11 | import nexaHeavy from "../../styles/type/nexaHeavy"; 12 | import latoLight from "../../styles/type/latoLight"; 13 | 14 | export const root = css({ 15 | position: "relative", 16 | }); 17 | 18 | export const aboutSheet = css({ 19 | ...sheet, 20 | maxWidth: regularMaxWidth + "rem", 21 | marginLeft: "auto", 22 | marginRight: "auto", 23 | }); 24 | 25 | export const title = css({ 26 | ...nexaHeavy, 27 | fontSize: headlineFontSize + "rem", 28 | lineHeight: headlineLineHeight + "rem", 29 | ":not(:last-child)": { 30 | marginBottom: rem(10) + "rem", 31 | }, 32 | }); 33 | 34 | export const text = css({ 35 | ...latoLight, 36 | fontSize: regularFontSize + "rem", 37 | lineHeight: regularLineHeight + "rem", 38 | }); 39 | -------------------------------------------------------------------------------- /spa/client/components/about/about.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import { aboutSheet, title, text } from "./about.css"; 3 | 4 | export default class About extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 |

About

10 |
11 |

12 | Delectus quia nulla sit ex ipsum sit animi incidunt. Nam rerum reiciendis et. Minus 13 | voluptatem natus mollitia temporibus. Molestias dolorem omnis eveniet repudiandae corporis 14 | voluptas sed quo. 15 |

16 |

17 | Quisquam a vel quia in quis blanditiis sed. Labore ratione minus. A quo consequuntur 18 | recusandae consequatur. Et aspernatur quod officia rem quam nisi vel est quidem. 19 |

20 | 21 |

22 | Alias et fugit error quaerat consequatur. Voluptatem omnis aut voluptatem. Et necessitatibus 23 | qui voluptatem. 24 |

25 |
26 |
27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /spa/client/components/allPosts/allPosts.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import getAllPosts from "../../api/posts/getAll"; 3 | import Posts from "../posts/posts"; 4 | import WithTitle from "../util/withTitle"; 5 | 6 | export default class AllPosts extends Component { 7 | render() { 8 | const title = "All Peerigon News"; 9 | 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spa/client/components/app/app.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { mintLight35, silverLight10, black } from "../../styles/colors"; 3 | import { linear } from "../../styles/gradients"; 4 | import { maxContentWidth } from "../../styles/layout"; 5 | import { paddingRegular } from "../../styles/paddings"; 6 | 7 | export const root = css({ 8 | margin: 0, 9 | color: black(), 10 | backgroundImage: linear("to bottom", [silverLight10(), mintLight35() + " 70vh"]), 11 | minHeight: "100vh", 12 | }); 13 | 14 | export const main = css({ 15 | maxWidth: maxContentWidth + "rem", 16 | marginLeft: "auto", 17 | marginRight: "auto", 18 | ["@media (min-width: " + paddingRegular * 20 + "px)"]: { 19 | padding: paddingRegular, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /spa/client/components/app/app.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import Header from "../header/header"; 3 | import Router from "../router/router"; 4 | import RoutePlaceholder from "../router/routePlaceholder"; 5 | import { root, main } from "./app.css"; 6 | 7 | export default class App extends Component { 8 | render() { 9 | return ( 10 | 11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /spa/client/components/formFeedback/formFeedback.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { asideFontSize, asideLineHeight } from "../../styles/typoSizes"; 3 | import { red } from "../../styles/colors"; 4 | import latoLight from "../../styles/type/latoLight"; 5 | import { msToSeconds } from "../../styles/timing"; 6 | 7 | export const transitionDuration = 100; 8 | 9 | const transitionDurationCss = msToSeconds(100) + "s"; 10 | 11 | export const overflowContainer = css({ 12 | display: "inline-block", 13 | overflow: "hidden", 14 | transition: `height ${ transitionDurationCss } ease-in-out`, 15 | }); 16 | 17 | export const message = css({ 18 | ...latoLight, 19 | display: "inline-block", 20 | color: red(), 21 | fontSize: asideFontSize + "rem", 22 | lineHeight: asideLineHeight + "rem", 23 | minHeight: asideLineHeight + "rem", 24 | transform: "translateY(0)", 25 | transition: `transform ${ transitionDurationCss } ease-in-out`, 26 | ":empty": { 27 | transform: "translateY(-100%)", 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /spa/client/components/formFeedback/formFeedback.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import { overflowContainer, message } from "./formFeedback.css"; 3 | 4 | export default class FormFeedback extends Component { 5 | render(props) { 6 | return ( 7 | 8 | 9 | {props.children} 10 | 11 | 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /spa/client/components/header/common.js: -------------------------------------------------------------------------------- 1 | import { rem } from "../../styles/scales"; 2 | 3 | // The type looks more vertically centered with this offset 4 | export const verticalOffset = 1; 5 | export const logoHeight = rem(17); 6 | export const headerCollapseBreakpoint = "@media (max-width: 35rem)"; 7 | -------------------------------------------------------------------------------- /spa/client/components/header/header.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { white, black } from "../../styles/colors"; 3 | import { px, rem } from "../../styles/scales"; 4 | import nexaHeavy from "../../styles/type/nexaHeavy"; 5 | import { maxContentWidth } from "../../styles/layout"; 6 | import { offscreen as a11yOffscreen } from "../../styles/a11y"; 7 | import { header as headerZIndex } from "../../styles/zIndex"; 8 | import { verticalOffset, logoHeight, headerCollapseBreakpoint } from "./common"; 9 | 10 | export const root = css({ 11 | position: "sticky", 12 | top: 0, 13 | zIndex: headerZIndex, 14 | color: black(), 15 | backgroundColor: white(), 16 | boxShadow: "0 5px 5px rgba(0, 0, 0, 0.1)", 17 | }); 18 | 19 | export const content = css({ 20 | display: "flex", 21 | alignItems: "center", 22 | lineHeight: logoHeight + "rem", 23 | flexWrap: "wrap", 24 | padding: [px(6) + verticalOffset, "px ", px(6), "px ", px(6) - verticalOffset, "px"].join(""), 25 | maxWidth: maxContentWidth + "rem", 26 | marginLeft: "auto", 27 | marginRight: "auto", 28 | }); 29 | 30 | export const logo = css({ 31 | display: "flex", 32 | alignItems: "center", 33 | textDecoration: "none", 34 | color: "currentColor", 35 | }); 36 | 37 | export const nav = css({ 38 | marginLeft: rem(12) + "rem", 39 | [headerCollapseBreakpoint]: { 40 | marginLeft: 0, 41 | width: "100%", 42 | order: 1, 43 | }, 44 | }); 45 | 46 | export const session = css({ 47 | marginLeft: "auto", 48 | }); 49 | 50 | export const headline = css({ 51 | ...nexaHeavy, 52 | fontSize: rem(13) + "rem", 53 | margin: 0, 54 | marginLeft: px(10), 55 | }); 56 | 57 | export const offscreen = css(a11yOffscreen); 58 | -------------------------------------------------------------------------------- /spa/client/components/header/header.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import Logo from "./logo/logo"; 3 | import Nav from "./nav/nav"; 4 | import Link from "../router/link"; 5 | import routes from "../../routes"; 6 | import { root, content, logo, nav, headline, session, offscreen } from "./header.css"; 7 | import Session from "./session/session"; 8 | 9 | export default class Header extends Component { 10 | render() { 11 | return ( 12 |
13 |
14 | 15 | 16 |

17 | Peerigon News 18 |

19 | 20 |
23 |
24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /spa/client/components/header/link.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px, rem } from "../../styles/scales"; 3 | import nexaXBold from "../../styles/type/nexaXBold"; 4 | import { regular as regularBorder } from "../../styles/borders"; 5 | 6 | const activeLinkStyles = { 7 | borderTop: regularBorder("transparent"), 8 | borderBottom: regularBorder(), 9 | }; 10 | 11 | export const activeLink = css(activeLinkStyles); 12 | 13 | export const link = css({ 14 | ...nexaXBold, 15 | color: "currentColor", 16 | fontSize: rem(12) + "rem", 17 | textDecoration: "none", 18 | padding: `2px ${ px(5) }px`, 19 | ":hover": activeLinkStyles, 20 | ":active": activeLinkStyles, 21 | }); 22 | -------------------------------------------------------------------------------- /spa/client/components/header/logo/logo.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { verticalOffset, logoHeight } from "../common"; 3 | 4 | export const logoImg = css({ 5 | position: "relative", 6 | display: "block", 7 | height: logoHeight + "rem", 8 | top: -verticalOffset, 9 | }); 10 | -------------------------------------------------------------------------------- /spa/client/components/header/logo/logo.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import logoSrc from "../../../assets/img/peerigonLogoMint.svg"; 3 | import { logoImg } from "./logo.css"; 4 | 5 | export default class Logo extends Component { 6 | render() { 7 | return ( 8 | {"Peerigon 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /spa/client/components/header/nav/nav.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px } from "../../../styles/scales"; 3 | 4 | export const list = css({ 5 | display: "flex", 6 | listStyleType: "none", 7 | }); 8 | 9 | export const listItem = css({ 10 | ":not(:last-child)": { 11 | marginRight: px(10), 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /spa/client/components/header/nav/nav.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import Link from "../../router/link"; 3 | import { list, listItem } from "./nav.css"; 4 | import { link, activeLink } from "../link.css"; 5 | import routes from "../../../routes"; 6 | import { nbsp } from "../../../util/htmlEntities"; 7 | 8 | export default class Nav extends Component { 9 | render(props) { 10 | return ( 11 | 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /spa/client/components/header/session/anonymous/anonymous.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import URLSearchParams from "url-search-params"; 3 | import Link from "../../../router/link"; 4 | import Modal from "../../../modal/modal"; 5 | import Placeholder from "../../../util/placeholder"; 6 | import { link } from "../../link.css"; 7 | import useDefault from "../../../../util/useDefault"; 8 | import RoutingContext from "../../../router/util/routingContext"; 9 | import { nbsp } from "../../../../util/htmlEntities"; 10 | 11 | function loadLoginForm() { 12 | return useDefault(import("../../../loginForm/loginForm")); 13 | } 14 | 15 | export default class Anonymous extends Component { 16 | constructor() { 17 | super(); 18 | this.loginFormProps = { 19 | handleLogin: this.handleLogin.bind(this), 20 | autoFocus: true, 21 | }; 22 | this.routingContext = new RoutingContext(this); 23 | } 24 | handleLogin() { 25 | const current = this.routingContext.current(); 26 | const paramsWithoutLogin = new URLSearchParams(window.location.search); 27 | 28 | paramsWithoutLogin.delete("showLogin"); 29 | 30 | this.routingContext.next(current.route, paramsWithoutLogin); 31 | } 32 | render() { 33 | const paramsAndShowLogin = new URLSearchParams(window.location.search); 34 | 35 | paramsAndShowLogin.set("showLogin", 1); 36 | 37 | return ( 38 |
39 | 40 | Log{nbsp}in 41 | 42 | 43 | 44 | 45 |
46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /spa/client/components/header/session/personal/personal.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px } from "../../../../styles/scales"; 3 | import latoLight from "../../../../styles/type/latoLight"; 4 | import { headerCollapseBreakpoint, logoHeight, verticalOffset } from "../../common"; 5 | import { regularFontSize } from "../../../../styles/typoSizes"; 6 | 7 | export const root = css({ 8 | display: "flex", 9 | alignItems: "center", 10 | fontSize: regularFontSize + "rem", 11 | // Avoids jumping header when the session state has changed 12 | lineHeight: 0, 13 | "> *:not(:last-child)": { 14 | marginRight: px(10), 15 | }, 16 | }); 17 | 18 | export const userName = css({ 19 | ...latoLight, 20 | position: "relative", 21 | top: -verticalOffset, 22 | [headerCollapseBreakpoint]: { 23 | display: "none", 24 | }, 25 | }); 26 | 27 | export const userImage = css({ 28 | position: "relative", 29 | top: -verticalOffset, 30 | display: "block", 31 | height: logoHeight + "rem", 32 | borderRadius: "100%", 33 | }); 34 | -------------------------------------------------------------------------------- /spa/client/components/header/session/personal/personal.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import destroySession from "../../../../api/session/destroy"; 3 | import { root, userName, userImage } from "./personal.css"; 4 | import { nbsp } from "../../../../util/htmlEntities"; 5 | import { link } from "../../link.css"; 6 | 7 | export default class Personal extends Component { 8 | constructor() { 9 | super(); 10 | this.handleLogout = this.handleLogout.bind(this); 11 | } 12 | handleLogout() { 13 | destroySession(); 14 | } 15 | render(props) { 16 | const user = props.user; 17 | 18 | if (user === null) { 19 | return null; 20 | } 21 | 22 | return ( 23 |
24 | {user.name} 25 | 26 | {user.name} 27 | 28 | 29 | 30 | Log{nbsp}out 31 | 32 | 33 |
34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /spa/client/components/header/session/session.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import Personal from "./personal/personal"; 3 | import Anonymous from "./anonymous/anonymous"; 4 | import localSession from "../../../api/session/local"; 5 | 6 | export default class Profile extends Component { 7 | render(props) { 8 | const user = localSession.user; 9 | 10 | return ( 11 |
12 | {user === null ? : } 13 |
14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spa/client/components/loading/loading.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import latoLight from "../../styles/type/latoLight"; 3 | import { rem } from "../../styles/scales"; 4 | 5 | export const root = css({ 6 | ...latoLight, 7 | fontSize: rem(13) + "rem", 8 | display: "flex", 9 | width: "100%", 10 | height: "100%", 11 | minHeight: rem(20) + "rem", 12 | alignItems: "center", 13 | justifyContent: "center", 14 | }); 15 | -------------------------------------------------------------------------------- /spa/client/components/loading/loading.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import { root } from "./loading.css"; 3 | 4 | export const loading =
Loading...
; 5 | 6 | export default class Loading extends Component { 7 | render() { 8 | return loading; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /spa/client/components/loginForm/loginForm.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { rem } from "../../styles/scales"; 3 | import sheet from "../../styles/block/sheet"; 4 | import { regularFontSize, regularLineHeight } from "../../styles/typoSizes"; 5 | import latoLight from "../../styles/type/latoLight"; 6 | import inputText from "../../styles/block/inputText"; 7 | import inputSubmit from "../../styles/block/inputSubmit"; 8 | import { paddingBigger } from "../../styles/paddings"; 9 | 10 | export const loginSheet = css({ 11 | ...sheet, 12 | ...latoLight, 13 | maxWidth: rem(29) + "rem", 14 | boxSizing: "border-box", 15 | padding: paddingBigger, 16 | fontSize: regularFontSize + "rem", 17 | lineHeight: regularLineHeight + "rem", 18 | display: "flex", 19 | flexDirection: "column", 20 | }); 21 | 22 | export const loginLabel = css({ 23 | ":after": { 24 | content: JSON.stringify(":"), 25 | }, 26 | }); 27 | 28 | export const loginInput = css({ 29 | ...inputText, 30 | }); 31 | 32 | export const formFeedback = css({ 33 | marginBottom: rem(10) + "rem", 34 | }); 35 | 36 | export const loginSubmit = css(inputSubmit); 37 | -------------------------------------------------------------------------------- /spa/client/components/loginForm/loginForm.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import FormFeedback from "../formFeedback/formFeedback"; 3 | import loginFormValidators from "./loginFormValidators"; 4 | import createSession from "../../api/session/create"; 5 | import { loginSheet, loginInput, loginLabel, loginSubmit, formFeedback } from "./loginForm.css"; 6 | import Form from "../form/form"; 7 | 8 | export default class LoginForm extends Component { 9 | constructor() { 10 | super(); 11 | this.renderForm = this.renderForm.bind(this); 12 | } 13 | renderForm({ id, errors, submitPending, submitError }) { 14 | const nameId = `${ id }-login-name`; 15 | const passwordId = `${ id }-login-password`; 16 | const { autoFocus = false } = this.props; 17 | 18 | /* eslint-disable react/jsx-key */ 19 | return [ 20 | , 23 | , 35 | 36 | {errors.get("name")} 37 | , 38 | , 41 | , 51 | 52 | {errors.get("password")} 53 | {submitError === null ? null : submitError.message} 54 | , 55 | , 62 | ]; 63 | /* eslint-enable react/jsx-key */ 64 | } 65 | render(props, state) { 66 | return ( 67 |
73 | {this.renderForm} 74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /spa/client/components/loginForm/loginFormValidators.js: -------------------------------------------------------------------------------- 1 | const validators = new Map(); 2 | 3 | validators.set("name", values => { 4 | const name = values.get("name"); 5 | 6 | if (name === "") { 7 | return "Missing login name"; 8 | } 9 | 10 | return null; 11 | }); 12 | validators.set("password", values => { 13 | const password = values.get("password"); 14 | 15 | if (password === "") { 16 | return "Missing password"; 17 | } 18 | 19 | return null; 20 | }); 21 | 22 | export default validators; 23 | -------------------------------------------------------------------------------- /spa/client/components/modal/modal.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import hexToRgba from "hex-to-rgba"; 3 | import { backdrop as backdropZIndex, modal as modalZIndex } from "../../styles/zIndex"; 4 | import { msToSeconds } from "../../styles/timing"; 5 | import { white, mint } from "../../styles/colors"; 6 | import { paddingRegular } from "../../styles/paddings"; 7 | import calc from "../../styles/calc"; 8 | 9 | export const fadeDuration = 100; 10 | const fadeDurationCss = msToSeconds(fadeDuration) + "s"; 11 | const backdropOpacity = 0.4; 12 | 13 | const backdropGlowAnimation = css.keyframes({ 14 | "50%": { 15 | backgroundColor: hexToRgba(mint(), "0.2"), 16 | }, 17 | }); 18 | 19 | export const root = css({ 20 | position: "absolute", 21 | top: 0, 22 | width: "100vw", 23 | height: "100vh", 24 | }); 25 | 26 | export const backdrop = css({ 27 | position: "fixed", 28 | top: 0, 29 | left: 0, 30 | right: 0, 31 | bottom: 0, 32 | zIndex: backdropZIndex, 33 | backgroundColor: "black", 34 | ":focus": { 35 | animation: `${ backdropGlowAnimation } infinite 3s ease-in-out`, 36 | }, 37 | }); 38 | 39 | export const backdropHidden = css({ 40 | opacity: 0, 41 | transition: `opacity ${ fadeDurationCss } ease-in-out, transform 0s ${ fadeDurationCss }`, 42 | transform: "translateX(100%)", 43 | }); 44 | 45 | export const backdropVisible = css({ 46 | opacity: backdropOpacity, 47 | transition: `opacity ${ fadeDurationCss } ease-in-out`, 48 | }); 49 | 50 | export const window = css({ 51 | position: "fixed", 52 | zIndex: modalZIndex, 53 | top: "50%", 54 | left: "50%", 55 | transform: "translate(-50%, -50%)", 56 | backgroundColor: white(), 57 | boxShadow: "0 7px 7px rgba(0, 0, 0, 0.3)", 58 | "> *": { 59 | width: calc("100vw - ", paddingRegular * 2, "px"), 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /spa/client/components/modal/modal.js: -------------------------------------------------------------------------------- 1 | import { Component, render as preactRender } from "preact"; 2 | import URLSearchParams from "url-search-params"; 3 | import WithContext from "../util/withContext"; 4 | import { root, backdrop, backdropHidden, backdropVisible, fadeDuration, window as modalWindow } from "./modal.css"; 5 | import GoBack from "../router/goBack"; 6 | 7 | function getBackParams(modalParam) { 8 | const params = new URLSearchParams(window.location.search); 9 | 10 | params.delete(modalParam); 11 | 12 | return params; 13 | } 14 | 15 | export default class Modal extends Component { 16 | constructor(props) { 17 | super(); 18 | this.setState({ active: false }); 19 | this.renderContainer = document.createElement("section"); 20 | this.updateActiveState(props); 21 | } 22 | componentWillReceiveProps(newProps) { 23 | this.updateActiveState(newProps); 24 | } 25 | componentWillUnmount() { 26 | // Trigger renderModal manually 27 | this.updateActiveState(this.props); 28 | this.renderModal(this.props, this.state); 29 | } 30 | updateActiveState(props) { 31 | this.setState(prevState => { 32 | const active = new URLSearchParams(location.search).has(props.activationParam) === true; 33 | 34 | if (prevState.active === true && active === false) { 35 | setTimeout(() => { 36 | if (this.state.active === true) { 37 | return; 38 | } 39 | document.body.removeChild(this.renderContainer); 40 | }, fadeDuration); 41 | } else if (prevState.active === false && active === true) { 42 | document.body.appendChild(this.renderContainer); 43 | // Trigger reflow to kick off the transition 44 | void this.renderContainer.offsetHeight; 45 | } 46 | 47 | return { 48 | active, 49 | }; 50 | }); 51 | } 52 | renderModal(props, state) { 53 | const backdropClass = [backdrop, state.active === true ? backdropVisible : backdropHidden]; 54 | 55 | preactRender( 56 | 57 |
58 | 59 | {props.render || state.active ? 60 |
61 | {props.children} 62 |
: 63 | null} 64 |
65 |
, 66 | this.renderContainer, 67 | this.renderContainer.firstElementChild 68 | ); 69 | } 70 | render(props, state) { 71 | this.renderModal(props, state); 72 | 73 | return null; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /spa/client/components/notFound/notFound.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import Header from "../header/header"; 3 | 4 | export default class NotFound extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 |

Not Found

10 |
11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spa/client/components/posts/post/post.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import nexaHeavy from "../../../styles/type/nexaHeavy"; 3 | import latoLight from "../../../styles/type/latoLight"; 4 | import { rem } from "../../../styles/scales"; 5 | import { 6 | regularFontSize, 7 | regularLineHeight, 8 | regularMaxWidth, 9 | headlineFontSize, 10 | headlineLineHeight, 11 | headlineMaxWidth, 12 | } from "../../../styles/typoSizes"; 13 | 14 | export const headline = css({ 15 | ...nexaHeavy, 16 | fontSize: headlineFontSize + "rem", 17 | lineHeight: headlineLineHeight + "rem", 18 | maxWidth: headlineMaxWidth + "rem", 19 | marginBottom: rem(6) + "rem", 20 | }); 21 | 22 | export const aside = css({ 23 | ...latoLight, 24 | display: "block", 25 | fontSize: rem(11) + "rem", 26 | lineHeight: rem(12) + "rem", 27 | marginBottom: rem(13) + "rem", 28 | }); 29 | 30 | export const paragraph = css({ 31 | ...latoLight, 32 | fontSize: regularFontSize + "rem", 33 | lineHeight: regularLineHeight + "rem", 34 | maxWidth: regularMaxWidth + "rem", 35 | ":not(:last-child)": { 36 | marginBottom: rem(10) + "rem", 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /spa/client/components/posts/post/post.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import fromNow from "from-now"; 3 | import { headline, paragraph, aside } from "./post.css"; 4 | 5 | const lineBreak = /\s*[\r\n]+\s*/g; 6 | 7 | export default class Post extends Component { 8 | render(props, state) { 9 | const post = props.post; 10 | 11 | return ( 12 |
13 |

14 | {post.title} 15 |

16 |
17 | 20 | {" ago by "} 21 | {post.author} 22 |
23 |
24 | {post.content.split(lineBreak).map(p => 25 | (

26 | {p} 27 |

) 28 | )} 29 |
30 |
31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /spa/client/components/posts/posts.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px, rem } from "../../styles/scales"; 3 | import { offscreen } from "../../styles/a11y"; 4 | import { regularMaxWidth } from "../../styles/typoSizes"; 5 | import sheet, { sheetPadding } from "../../styles/block/sheet"; 6 | 7 | export const root = css({ 8 | position: "relative", 9 | }); 10 | 11 | export const a11yTitle = css({ 12 | ...offscreen, 13 | }); 14 | 15 | export const postImage = css({ 16 | position: "absolute", 17 | maxWidth: px(30), 18 | marginTop: sheetPadding, 19 | transform: "translate(0%)", 20 | transition: "transform 0.3s ease-in-out", 21 | }); 22 | 23 | export const postSheet = css({ 24 | ...sheet, 25 | // position relative is necessary so that the position absolute image is still below the sheet 26 | position: "relative", 27 | maxWidth: regularMaxWidth + "rem", 28 | }); 29 | 30 | export const postContainer = css({ 31 | position: "relative", 32 | overflow: "hidden", 33 | ":not(:last-child)": { 34 | marginBottom: rem(15) + "rem", 35 | }, 36 | [":nth-child(odd) ." + postSheet]: { 37 | marginLeft: "auto", 38 | }, 39 | [":nth-child(odd) ." + postImage]: { 40 | left: px(17), 41 | }, 42 | [":nth-child(even) ." + postImage]: { 43 | right: px(17), 44 | }, 45 | [":nth-child(odd):not(:hover) ." + postImage]: { 46 | transform: "translate(10%)", 47 | }, 48 | [":nth-child(even):not(:hover) ." + postImage]: { 49 | transform: "translate(-10%)", 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /spa/client/components/posts/posts.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import AsyncPropsCache from "../../util/asyncPropsCache"; 3 | import Post from "./post/post"; 4 | import { a11yTitle, root, postContainer, postSheet, postImage } from "./posts.css"; 5 | 6 | const empty = []; 7 | 8 | export default class Posts extends Component { 9 | constructor(props) { 10 | super(); 11 | this.asyncPropsCache = new AsyncPropsCache(this, { 12 | posts: props.posts, 13 | }); 14 | } 15 | render(props, state) { 16 | const posts = state.posts; 17 | 18 | return ( 19 |
20 |

21 | {props.a11yTitle} 22 |

23 | {posts === null ? 24 | empty : 25 | posts.map(post => 26 | (
27 | {post.title} 28 | 29 |
) 30 | )} 31 |
32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /spa/client/components/router/goBack.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import Link from "./link"; 3 | 4 | export default class GoBack extends Component { 5 | render(props) { 6 | return ( 7 | 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /spa/client/components/router/link.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import createEventHandler from "../../util/createEventHandler"; 3 | import routeToHref from "./util/routeToHref"; 4 | 5 | export default class Link extends Component { 6 | constructor() { 7 | super(); 8 | 9 | const preloadNextComponent = this.preloadNextComponent.bind(this); 10 | 11 | this.handleMouseOver = createEventHandler(this, "onMouseOver", preloadNextComponent); 12 | this.handleFocus = createEventHandler(this, "onFocus", preloadNextComponent); 13 | } 14 | preloadNextComponent() { 15 | const route = this.props.route; 16 | const component = route && route.component; 17 | 18 | if (typeof component === "function") { 19 | component(); 20 | } 21 | } 22 | splitProps(props) { 23 | const aProps = (this.aProps = {}); 24 | const ownProps = (this.ownProps = { 25 | route: props.route || this.context.route, 26 | params: props.params || null, 27 | children: props.children, 28 | replaceRoute: Boolean(props.replaceRoute), 29 | activeClass: props.activeClass || "", 30 | }); 31 | 32 | Object.keys(props).filter(key => key in ownProps === false).forEach(key => { 33 | aProps[key] = props[key]; 34 | }); 35 | } 36 | render() { 37 | this.splitProps(this.props); 38 | 39 | const { route, params, children, replaceRoute, activeClass } = this.ownProps; 40 | const classes = [route === this.context.route ? activeClass : "", this.aProps.class]; 41 | 42 | return ( 43 | 52 | {children} 53 | 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /spa/client/components/router/routePlaceholder.js: -------------------------------------------------------------------------------- 1 | import Placeholder from "../util/placeholder"; 2 | 3 | const defaultParams = {}; 4 | 5 | export default class RoutePlaceholder { 6 | render(props, state) { 7 | const route = this.context.route; 8 | 9 | return ; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /spa/client/components/router/router.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import URLSearchParams from "url-search-params"; 3 | import nanorouter from "nanorouter"; 4 | import onLinkClick from "nanohref"; 5 | import onHistoryPop from "nanohistory"; 6 | import routes from "../../routes"; 7 | import trigger from "./util/trigger"; 8 | 9 | function createRouteHandler(route, component) { 10 | return urlParams => { 11 | const params = new URLSearchParams(window.location.search); 12 | 13 | for (const key of Object.keys(urlParams)) { 14 | params.set(key, urlParams[key]); 15 | } 16 | 17 | component.setState({ 18 | previousRoute: component.state.route || null, 19 | previousParams: component.state.params || null, 20 | route, 21 | params, 22 | }); 23 | }; 24 | } 25 | 26 | function createRouter(routes, component) { 27 | const router = nanorouter({ default: "/404" }); 28 | 29 | Object.values(routes).forEach(route => { 30 | router.on(route.match, createRouteHandler(route, component)); 31 | }); 32 | 33 | onHistoryPop(location => { 34 | router(location.pathname); 35 | }); 36 | onLinkClick(node => { 37 | const href = node.href; 38 | 39 | if (node.hasAttribute("data-route") === false) { 40 | window.location = href; 41 | 42 | return; 43 | } 44 | trigger(router, node.href, { 45 | replaceRoute: node.hasAttribute("data-replace-url") ? true : undefined, 46 | }); 47 | }); 48 | 49 | return router; 50 | } 51 | 52 | export default class Router extends Component { 53 | constructor() { 54 | super(); 55 | this.router = createRouter(routes, this); 56 | this.router(location.pathname); 57 | } 58 | getChildContext() { 59 | return { 60 | route: this.state.route || null, 61 | params: this.state.params || null, 62 | previousRoute: this.state.previousRoute || null, 63 | previousParams: this.state.previousParams || null, 64 | router: this.router, 65 | }; 66 | } 67 | componentWillUnmount() { 68 | // We cannot undo the side-effects introduced by the router 69 | throw new Error("Cannot unmount router: The router is intended to be used top-level"); 70 | } 71 | render(props, state) { 72 | return props.children[0]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /spa/client/components/router/util/routeToHref.js: -------------------------------------------------------------------------------- 1 | export default function routeToHref(route, params) { 2 | if (params === null) { 3 | return route.match; 4 | } 5 | 6 | let href = route.match; 7 | 8 | for (const key of params.keys()) { 9 | const pattern = ":" + key; 10 | const patternIdx = href.indexOf(pattern); 11 | 12 | if (patternIdx > -1) { 13 | params.delete(key); 14 | 15 | href = href.slice(0, patternIdx - 1) + params.get(key) + href.slice(patternIdx + pattern.length); 16 | } 17 | } 18 | 19 | const paramString = params.toString(); 20 | 21 | if (paramString === "") { 22 | return href; 23 | } 24 | 25 | return href + "?" + paramString; 26 | } 27 | -------------------------------------------------------------------------------- /spa/client/components/router/util/routingContext.js: -------------------------------------------------------------------------------- 1 | import trigger from "./trigger"; 2 | import routeToHref from "./routeToHref"; 3 | 4 | export default class RoutingContext { 5 | constructor(component) { 6 | this.component = component; 7 | } 8 | previous() { 9 | return { 10 | route: this.component.context.previousRoute, 11 | params: this.component.context.previousParams, 12 | }; 13 | } 14 | current() { 15 | return { 16 | route: this.component.context.route, 17 | params: this.component.context.params, 18 | }; 19 | } 20 | next(route, params = null, options) { 21 | trigger(this.component.context.router, routeToHref(route, params), options); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /spa/client/components/router/util/trigger.js: -------------------------------------------------------------------------------- 1 | export default function trigger(router, href, { replaceRoute = href === window.location.href } = {}) { 2 | const saveState = replaceRoute === true ? window.history.replaceState : window.history.pushState; 3 | 4 | saveState.call(history, {}, "", href); 5 | router(location.pathname); 6 | } 7 | -------------------------------------------------------------------------------- /spa/client/components/top5/top5.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import getTop5 from "../../api/posts/getTop5"; 3 | import Posts from "../posts/posts"; 4 | import WithTitle from "../util/withTitle"; 5 | 6 | export default class Top5 extends Component { 7 | render() { 8 | const title = "Top 5 Peerigon News"; 9 | 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spa/client/components/util/placeholder.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import Loading from "../loading/loading"; 3 | import AsyncPropsCache from "../../util/asyncPropsCache"; 4 | 5 | export default class Placeholder extends Component { 6 | constructor(props) { 7 | super(); 8 | this.asyncPropsCache = new AsyncPropsCache(this, { 9 | component: props.component, 10 | }); 11 | } 12 | render(props, state) { 13 | const Component = state.component; 14 | 15 | if (Component !== null) { 16 | return ; 17 | } 18 | 19 | const children = props.children; 20 | 21 | if (children.length === 0) { 22 | return ; 23 | } 24 | 25 | const childGenerator = children[0]; 26 | 27 | return childGenerator(state.componentError); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /spa/client/components/util/withContext.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | 3 | export default class WithContext extends Component { 4 | getChildContext() { 5 | return this.props.context; 6 | } 7 | render(props) { 8 | return props.children[0]; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /spa/client/components/util/withTitle.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | 3 | export default class WithTitle extends Component { 4 | componentWillReceiveProps(props) { 5 | this.updateTitle(props.title); 6 | } 7 | componentWillMount() { 8 | this.updateTitle(this.props.title); 9 | } 10 | updateTitle(title) { 11 | if (title !== undefined) { 12 | document.title = title; 13 | } 14 | } 15 | render(props) { 16 | return props.children[0]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spa/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Peerigon News 11 | 12 | 13 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /spa/client/index.js: -------------------------------------------------------------------------------- 1 | require("./init/globals"); 2 | require("./init/styles"); 3 | require("./init/serviceWorker"); 4 | require("./init/render"); 5 | -------------------------------------------------------------------------------- /spa/client/init/globals.js: -------------------------------------------------------------------------------- 1 | // A place for global variables and polyfills 2 | import { h } from "preact"; 3 | 4 | window.h = h; 5 | -------------------------------------------------------------------------------- /spa/client/init/render.js: -------------------------------------------------------------------------------- 1 | import { render as preactRender } from "preact"; 2 | import App from "../components/app/app"; 3 | 4 | function render() { 5 | preactRender(, document.body); 6 | } 7 | 8 | document.addEventListener("DOMContentLoaded", render); 9 | -------------------------------------------------------------------------------- /spa/client/init/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // TODO 2 | export default function register() { 3 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 4 | window.addEventListener("load", () => { 5 | navigator.serviceWorker 6 | // Provided by the sw-precache-webpack-plugin 7 | .register("/service-worker.js") 8 | .then(registration => { 9 | registration.onupdatefound = () => { 10 | const installingWorker = registration.installing; 11 | 12 | installingWorker.onstatechange = () => { 13 | if (installingWorker.state === "installed") { 14 | if (navigator.serviceWorker.controller) { 15 | console.log("New content is available; please refresh."); 16 | } else { 17 | console.log("Content is cached for offline use."); 18 | } 19 | } 20 | }; 21 | }; 22 | }) 23 | .catch(error => { 24 | console.error("Error during service worker registration:", error); 25 | }); 26 | }); 27 | } 28 | } 29 | 30 | export function unregister() { 31 | if ("serviceWorker" in navigator) { 32 | navigator.serviceWorker.ready.then(registration => { 33 | registration.unregister(); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /spa/client/init/styles.js: -------------------------------------------------------------------------------- 1 | // Pre-styles will be extracted and inlined into the index.html 2 | import "../styles/pre/reset.css"; 3 | import "../styles/pre/body.css"; 4 | -------------------------------------------------------------------------------- /spa/client/routes.js: -------------------------------------------------------------------------------- 1 | import useDefault from "./util/useDefault"; 2 | 3 | export default { 4 | top5: { 5 | match: "/", 6 | component: () => useDefault(import("./components/top5/top5" /* webpackChunkName: "posts" */)), 7 | }, 8 | allPosts: { 9 | match: "/all", 10 | component: () => useDefault(import("./components/allPosts/allPosts" /* webpackChunkName: "posts" */)), 11 | }, 12 | about: { 13 | match: "/about", 14 | component: () => useDefault(import("./components/about/about" /* webpackChunkName: "about" */)), 15 | }, 16 | notFound: { 17 | match: "/404", 18 | component: () => useDefault(import("./components/notFound/notFound" /* webpackChunkName: "notFound" */)), 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /spa/client/styles/a11y.js: -------------------------------------------------------------------------------- 1 | export const offscreen = { 2 | position: "absolute", 3 | left: -10000, 4 | top: "auto", 5 | width: 1, 6 | height: 1, 7 | overflow: "hidden", 8 | }; 9 | -------------------------------------------------------------------------------- /spa/client/styles/block/inputSubmit.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { rem } from "../../styles/scales"; 3 | import { mint } from "../../styles/colors"; 4 | import { regularFontSize, regularLineHeight } from "../../styles/typoSizes"; 5 | import nexaXBold from "../../styles/type/nexaXBold"; 6 | import { regular } from "../borders"; 7 | import { repeatingLinear } from "../gradients"; 8 | 9 | const stripeColor = "rgba(0, 0, 0, 0.1)"; 10 | const stripeAnimation = css.keyframes({ 11 | from: { 12 | backgroundPosition: "0 0", 13 | }, 14 | to: { 15 | backgroundPosition: "71px 0px", 16 | }, 17 | }); 18 | // Mobile safari adds weird styles 19 | const mobileSafariStyleFixes = { 20 | WebkitAppearance: "none", 21 | borderRadius: 0, 22 | }; 23 | 24 | export default { 25 | ...nexaXBold, 26 | ...mobileSafariStyleFixes, 27 | width: "100%", 28 | fontSize: regularFontSize + "rem", 29 | lineHeight: regularLineHeight + "rem", 30 | padding: rem(7) + "rem 0", 31 | border: "none", 32 | outline: "none", 33 | backgroundColor: mint(), 34 | boxShadow: "0 0px 0px rgba(0, 0, 0, 0.3)", 35 | transition: "box-shadow 0.1s ease-in-out", 36 | ":hover": { 37 | cursor: "pointer", 38 | boxShadow: "3px 3px 3px rgba(0, 0, 0, 0.2), -3px 3px 3px rgba(0, 0, 0, 0.2)", 39 | }, 40 | ":focus": { 41 | outline: regular(), 42 | }, 43 | "[data-pending]": { 44 | backgroundImage: repeatingLinear("-45deg", [ 45 | "transparent 0", 46 | "transparent 25px", 47 | stripeColor + " 25px", 48 | stripeColor + " 50px", 49 | ]), 50 | backgroundSize: "71px 50px", 51 | animation: stripeAnimation + " 2s linear infinite", 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /spa/client/styles/block/inputText.js: -------------------------------------------------------------------------------- 1 | import { rem } from "../../styles/scales"; 2 | import { mint, red } from "../../styles/colors"; 3 | import { regularFontSize, regularLineHeight } from "../../styles/typoSizes"; 4 | import latoLight from "../../styles/type/latoLight"; 5 | import { regular } from "../borders"; 6 | 7 | const mobileSafariStyleFixes = { 8 | WebkitAppearance: "none", 9 | borderRadius: 0, 10 | }; 11 | 12 | export default { 13 | ...latoLight, 14 | ...mobileSafariStyleFixes, 15 | width: "100%", 16 | fontSize: regularFontSize + "rem", 17 | lineHeight: regularLineHeight + "rem", 18 | padding: rem(7) + "rem 0", 19 | border: "none", 20 | borderBottom: regular(), 21 | outline: "none", 22 | ":focus": { 23 | borderColor: mint(), 24 | outlineColor: mint(), 25 | }, 26 | "[invalid]": { 27 | borderBottomColor: red(), 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /spa/client/styles/block/sheet.js: -------------------------------------------------------------------------------- 1 | import { px } from "../../styles/scales"; 2 | import { white, black } from "../../styles/colors"; 3 | 4 | export const sheetPadding = px(13); 5 | 6 | export default { 7 | color: black(), 8 | backgroundColor: white(), 9 | padding: sheetPadding, 10 | }; 11 | -------------------------------------------------------------------------------- /spa/client/styles/borders.js: -------------------------------------------------------------------------------- 1 | import { black } from "./colors"; 2 | 3 | export const defaultColor = black(); 4 | export const regularWidth = 2; 5 | export const strongWidth = 4; 6 | 7 | export function regular(color = defaultColor) { 8 | return `${ regularWidth }px solid ${ color }`; 9 | } 10 | 11 | export function strong(color = defaultColor) { 12 | return `${ strongWidth }px solid ${ color }`; 13 | } 14 | -------------------------------------------------------------------------------- /spa/client/styles/calc.js: -------------------------------------------------------------------------------- 1 | export default function (...bits) { 2 | return `calc(${ bits.join("") })`; 3 | } 4 | -------------------------------------------------------------------------------- /spa/client/styles/colors.js: -------------------------------------------------------------------------------- 1 | import { color, lightness } from "kewler"; 2 | 3 | export const mint = color("#46e1c8"); 4 | export const mintLight35 = mint(lightness(35)); 5 | export const white = color("#fff"); 6 | export const silver = color("#e6e1de"); 7 | export const silverLight10 = silver(lightness(10)); 8 | export const black = color("#282828"); 9 | export const blackLight30 = black(lightness(40)); 10 | export const red = color("#f14936"); 11 | -------------------------------------------------------------------------------- /spa/client/styles/gradients.js: -------------------------------------------------------------------------------- 1 | export function linear(dir, colorStops) { 2 | return `linear-gradient(${ dir }, ${ colorStops.join(", ") })`; 3 | } 4 | 5 | export function repeatingLinear(dir, colorStops) { 6 | return `repeating-linear-gradient(${ dir }, ${ colorStops.join(", ") })`; 7 | } 8 | -------------------------------------------------------------------------------- /spa/client/styles/layout.js: -------------------------------------------------------------------------------- 1 | import { rem } from "./scales"; 2 | 3 | export const maxContentWidth = rem(34); 4 | -------------------------------------------------------------------------------- /spa/client/styles/paddings.js: -------------------------------------------------------------------------------- 1 | import { px } from "./scales"; 2 | 3 | export const paddingRegular = px(14); 4 | export const paddingBigger = px(15); 5 | -------------------------------------------------------------------------------- /spa/client/styles/pre/body.css: -------------------------------------------------------------------------------- 1 | body { 2 | position: relative; 3 | } -------------------------------------------------------------------------------- /spa/client/styles/pre/reset.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } -------------------------------------------------------------------------------- /spa/client/styles/scales.js: -------------------------------------------------------------------------------- 1 | const pxBase = 2; 2 | const pxRatio = 6 / 5; 3 | const remBase = 2 / 16; 4 | const remRatio = 6 / 5; 5 | const regularDevicePixelRatio = 2; 6 | 7 | export function px(factor) { 8 | const size = Math.pow(pxRatio, factor) * pxBase; 9 | 10 | // Round to whole device pixels 11 | return Math.floor(size * regularDevicePixelRatio) / regularDevicePixelRatio; 12 | } 13 | 14 | export function rem(factor) { 15 | return Math.pow(remRatio, factor) * remBase; 16 | } 17 | -------------------------------------------------------------------------------- /spa/client/styles/timing.js: -------------------------------------------------------------------------------- 1 | export function msToSeconds(ms) { 2 | return ms / 1000; 3 | } 4 | -------------------------------------------------------------------------------- /spa/client/styles/type/fonts/latoLight.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Lato"; 3 | src: local("Lato Light"), 4 | url("../../../assets/fonts/latoLatinLight.woff2") format("woff2"), 5 | url("../../../assets/fonts/latoLatinLight.woff") format("woff"), 6 | url("../../../assets/fonts/latoLatinLight.ttf") format("truetype"); 7 | font-weight: 200; 8 | font-style: normal; 9 | } -------------------------------------------------------------------------------- /spa/client/styles/type/fonts/nexaHeavy.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Nexa"; 3 | src: local("Nexa Heavy"), 4 | url("../../../assets/fonts/nexaHeavyWebfont.woff2") format("woff2"), 5 | url("../../../assets/fonts/nexaHeavyWebfont.woff") format("woff"), 6 | url("../../../assets/fonts/nexaHeavyWebfont.ttf") format("truetype"); 7 | font-weight: 900; 8 | font-style: normal; 9 | } -------------------------------------------------------------------------------- /spa/client/styles/type/fonts/nexaXbold.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Nexa"; 3 | src: local("Nexa XBold"), 4 | url("../../../assets/fonts/nexaExtraboldWebfont.woff2") format("woff2"), 5 | url("../../../assets/fonts/nexaExtraboldWebfont.woff") format("woff"), 6 | url("../../../assets/fonts/nexaExtraboldWebfont.ttf") format("truetype"); 7 | font-weight: 700; 8 | font-style: normal; 9 | } -------------------------------------------------------------------------------- /spa/client/styles/type/latoLight.js: -------------------------------------------------------------------------------- 1 | import "./fonts/latoLight.css"; // eslint-disable-line import/no-unassigned-import 2 | 3 | export default { 4 | fontFamily: "Lato", 5 | fontWeight: 200, 6 | fontStyle: "normal", 7 | }; 8 | -------------------------------------------------------------------------------- /spa/client/styles/type/nexaHeavy.js: -------------------------------------------------------------------------------- 1 | import "./fonts/nexaHeavy.css"; // eslint-disable-line import/no-unassigned-import 2 | 3 | export default { 4 | fontFamily: "Nexa", 5 | fontWeight: 900, 6 | fontStyle: "normal", 7 | }; 8 | -------------------------------------------------------------------------------- /spa/client/styles/type/nexaXBold.js: -------------------------------------------------------------------------------- 1 | import "./fonts/nexaXBold.css"; // eslint-disable-line import/no-unassigned-import 2 | 3 | export default { 4 | fontFamily: "Nexa", 5 | fontWeight: 700, 6 | fontStyle: "normal", 7 | }; 8 | -------------------------------------------------------------------------------- /spa/client/styles/typoSizes.js: -------------------------------------------------------------------------------- 1 | import { rem } from "./scales"; 2 | 3 | export const regularFontSize = rem(12); 4 | export const regularLineHeight = rem(14); 5 | export const regularMaxWidth = rem(32); 6 | 7 | export const headlineFontSize = rem(14); 8 | export const headlineLineHeight = rem(15); 9 | export const headlineMaxWidth = rem(30); 10 | 11 | export const asideFontSize = rem(11); 12 | export const asideLineHeight = rem(12); 13 | export const asideMaxWidth = rem(30); 14 | -------------------------------------------------------------------------------- /spa/client/styles/zIndex.js: -------------------------------------------------------------------------------- 1 | export const header = 2; 2 | export const modal = 3; 3 | export const backdrop = 1; 4 | -------------------------------------------------------------------------------- /spa/client/util/asyncContext.js: -------------------------------------------------------------------------------- 1 | export default class AsyncContext { 2 | constructor(component) { 3 | const unmountHandler = component.componentWillUnmount; 4 | 5 | this.component = component; 6 | this.pending = new Map(); 7 | 8 | component.componentWillUnmount = (...args) => { 9 | const result = unmountHandler ? unmountHandler.apply(component, args) : undefined; 10 | 11 | this.reset(); 12 | 13 | return result; 14 | }; 15 | } 16 | add(name, promise, initialValue) { 17 | const proceed = () => this.pending.get(name) === promise; 18 | 19 | this.setStartState(name, initialValue); 20 | this.pending.set(name, promise); 21 | 22 | return promise.then( 23 | res => { 24 | if (proceed() === true) { 25 | this.pending.delete("name"); 26 | this.setSuccessState(name, res); 27 | 28 | return res; 29 | } 30 | 31 | return null; 32 | }, 33 | err => { 34 | if (proceed() === true) { 35 | this.pending.delete("name"); 36 | this.setFailState(name, err); 37 | 38 | throw err; 39 | } 40 | 41 | console.log("An error happened in an abandoned async context"); 42 | console.log(err); 43 | 44 | return null; 45 | } 46 | ); 47 | } 48 | setStartState(name, initialValue) { 49 | this.component.setState({ 50 | [name + "Pending"]: true, 51 | [name + "Error"]: null, 52 | [name]: initialValue, 53 | }); 54 | } 55 | setSuccessState(name, result) { 56 | this.component.setState({ 57 | [name + "Pending"]: false, 58 | [name + "Error"]: null, 59 | [name]: result, 60 | }); 61 | } 62 | setFailState(name, error) { 63 | this.component.setState({ 64 | [name + "Pending"]: false, 65 | [name + "Error"]: error, 66 | [name]: null, 67 | }); 68 | } 69 | reset() { 70 | this.pending.clear(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /spa/client/util/asyncPropsCache.js: -------------------------------------------------------------------------------- 1 | import AsyncContext from "./asyncContext"; 2 | 3 | export default class AsyncPropsCache { 4 | constructor(component, asyncProps) { 5 | const willReceiveProps = component.componentWillReceiveProps; 6 | 7 | this.component = component; 8 | this.asyncProps = asyncProps; 9 | this.async = new AsyncContext(component); 10 | this.cache = new Map(); 11 | 12 | component.componentWillReceiveProps = (...args) => { 13 | const nextProps = args[0]; 14 | const result = willReceiveProps ? willReceiveProps.apply(component, args) : undefined; 15 | 16 | this.fetchNewProps(nextProps); 17 | 18 | return result; 19 | }; 20 | 21 | this.fetchNewProps(asyncProps); 22 | } 23 | fetchNewProps(props) { 24 | Object.keys(this.asyncProps) 25 | .filter(key => this.cache.has(key) === false || this.cache.get(key) !== props[key]) 26 | .forEach(key => { 27 | const prop = props[key]; 28 | 29 | this.async.add(key, prop(), null); 30 | this.cache.set(key, prop); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /spa/client/util/createEventHandler.js: -------------------------------------------------------------------------------- 1 | export default function createEventHandler(component, eventProp, handler) { 2 | return e => { 3 | const originalEventHandler = component.props[eventProp]; 4 | 5 | handler.call(component, e); 6 | if (typeof originalEventHandler === "function") { 7 | originalEventHandler.call(e.currentTarget, e); 8 | } 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /spa/client/util/generateId.js: -------------------------------------------------------------------------------- 1 | let counter = 0; 2 | 3 | export default function generateId() { 4 | return "app-id-" + counter++; 5 | } 6 | -------------------------------------------------------------------------------- /spa/client/util/htmlEntities.js: -------------------------------------------------------------------------------- 1 | export const nbsp = "\u00a0"; 2 | -------------------------------------------------------------------------------- /spa/client/util/mapToObject.js: -------------------------------------------------------------------------------- 1 | export default function mapToObject(map) { 2 | const obj = Object.create(null); 3 | 4 | for (const [key, value] of map.entries()) { 5 | obj[key] = value; 6 | } 7 | 8 | return obj; 9 | } 10 | -------------------------------------------------------------------------------- /spa/client/util/useDefault.js: -------------------------------------------------------------------------------- 1 | export default function useDefault(p) { 2 | return p.then(e => e.default); 3 | } 4 | -------------------------------------------------------------------------------- /spa/config/server.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "hostname": "localhost", 4 | "bodyLimit": "100kb", 5 | "corsHeaders": [ 6 | "Link" 7 | ], 8 | "responseDelay": 300 9 | } -------------------------------------------------------------------------------- /spa/server/dummyData/generate.js: -------------------------------------------------------------------------------- 1 | import filledArray from "filled-array"; 2 | import faker from "faker"; 3 | 4 | function posts() { 5 | return filledArray( 6 | i => ({ 7 | id: faker.random.uuid(), 8 | title: faker.lorem.sentence(), 9 | content: faker.lorem.paragraphs(), 10 | author: faker.name.findName(), 11 | published: faker.date.past(), 12 | starred: Math.floor(Math.random() * 100), 13 | image: `/postImage${ i % 4 + 1 }.jpg`, 14 | }), 15 | 30 16 | ); 17 | } 18 | 19 | posts(); 20 | -------------------------------------------------------------------------------- /spa/server/dummyData/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "353e4bdf-7436-41c1-a25c-25f0667692d9", 4 | "name": "jhnns", 5 | "image": "userImage.jpg" 6 | } 7 | ] -------------------------------------------------------------------------------- /spa/server/env.js: -------------------------------------------------------------------------------- 1 | const env = process.env.NODE_ENV || "development"; 2 | 3 | export const isProd = env === "production"; 4 | export const isDev = isProd === false; 5 | 6 | export default env; 7 | -------------------------------------------------------------------------------- /spa/server/index.js: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import path from "path"; 3 | import express from "express"; 4 | import cors from "cors"; 5 | import morgan from "morgan"; 6 | import connectGzipStatic from "connect-gzip-static"; 7 | import helmet from "helmet"; 8 | import config from "../config/server"; 9 | import api from "./api"; 10 | 11 | const app = express(); 12 | const pathToIndexHtml = path.resolve(__dirname, "..", "public", "index.html"); 13 | 14 | app.server = http.createServer(app); 15 | 16 | app.use(morgan("dev")); 17 | app.use(helmet()); 18 | app.use( 19 | cors({ 20 | exposedHeaders: config.corsHeaders, 21 | }) 22 | ); 23 | api(app); 24 | app.use( 25 | connectGzipStatic(path.resolve(__dirname, "..", "public"), { 26 | // We use hashed filenames, a long max age is ok 27 | maxAge: 365 * 24 * 60 * 60 * 1000, 28 | }) 29 | ); 30 | app.use((req, res, next) => { 31 | res.sendFile(pathToIndexHtml); 32 | }); 33 | 34 | app.server.listen(process.env.PORT || config.port, config.hostname || "localhost", () => { 35 | console.log(`Started on port ${ app.server.address().port }`); 36 | }); 37 | 38 | export default app; 39 | -------------------------------------------------------------------------------- /spa/tools/webpack/InlinePreStylesPlugin.js: -------------------------------------------------------------------------------- 1 | class InlinePreStylesPlugin { 2 | apply(compiler) { 3 | // Hook into the html-webpack-plugin processing 4 | compiler.plugin("compilation", compilation => { 5 | compilation.plugin("html-webpack-plugin-alter-asset-tags", (htmlPluginData, callback) => { 6 | const assets = compilation.assets; 7 | const styleTags = Object.keys(assets) 8 | .filter(file => /\.pre\.css$/.test(file) === true) 9 | .map(file => assets[file].source().toString("utf8")) 10 | .map(src => ({ 11 | tagName: "style", 12 | closeTag: true, 13 | attributes: { 14 | type: "text/css", 15 | }, 16 | innerHTML: src, 17 | })); 18 | 19 | htmlPluginData.head = htmlPluginData.head.concat(styleTags); 20 | 21 | callback(null, htmlPluginData); 22 | }); 23 | }); 24 | } 25 | } 26 | 27 | module.exports = InlinePreStylesPlugin; 28 | -------------------------------------------------------------------------------- /spa/tools/webpack/exportCssLoader.js: -------------------------------------------------------------------------------- 1 | const exportCss = `module.exports = (({ renderStatic }, oldExports) => { 2 | const oldKeys = Object.keys(oldExports); 3 | const locals = {}; 4 | const { css } = renderStatic(() => { 5 | oldKeys.forEach(key => { 6 | const exportValue = oldExports[key]; 7 | 8 | if (exportValue !== undefined && exportValue !== null) { 9 | locals[key] = exportValue.toString(); 10 | } 11 | }); 12 | 13 | return ""; 14 | }); 15 | const newExports = [[module.id, css]]; 16 | 17 | Object.assign(newExports, oldExports); 18 | newExports.locals = locals; 19 | 20 | return newExports; 21 | })(require("glamor/server"), module.exports);`; 22 | 23 | module.exports = function (source, sourceMaps) { 24 | this.callback(null, source + ";" + exportCss, sourceMaps); 25 | }; 26 | -------------------------------------------------------------------------------- /universal/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "presets": [ 5 | [ 6 | "env", 7 | { 8 | "targets": "current" 9 | } 10 | ] 11 | ] 12 | }, 13 | "browser": { 14 | "presets": [ 15 | [ 16 | "env", 17 | { 18 | "targets": { 19 | "browsers": ["last 2 versions"] 20 | }, 21 | "modules": false 22 | } 23 | ] 24 | ] 25 | } 26 | }, 27 | "plugins": [ 28 | [ 29 | "transform-react-jsx", 30 | { 31 | "pragma": "h" 32 | } 33 | ], 34 | "transform-react-constant-elements", 35 | "syntax-dynamic-import", 36 | "transform-runtime", 37 | "transform-object-rest-spread" 38 | ], 39 | "ignore": ["dist/**", "node_modules/**"], 40 | "retainLines": true 41 | } 42 | -------------------------------------------------------------------------------- /universal/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["peerigon"], 3 | "env": { 4 | "node": true, 5 | "browser": false 6 | }, 7 | "root": true, 8 | "rules": { 9 | "no-mixed-operators": "off" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /universal/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | /public 61 | /dist -------------------------------------------------------------------------------- /universal/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug app:client:build", 11 | "env": { 12 | "WEBPACK_ENV": "production", 13 | "WEBPACK_TARGET": "browser" 14 | }, 15 | "program": "${workspaceRoot}/node_modules/.bin/webpack", 16 | "args": [ 17 | "--config", 18 | "config/webpack.config.babel.js" 19 | ] 20 | }, 21 | { 22 | "type": "node", 23 | "request": "launch", 24 | "name": "Debug server", 25 | "env": {}, 26 | "program": "${workspaceRoot}/dist/server/index.js" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /universal/app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jsx-a11y"], 3 | "extends": ["peerigon/react", "plugin:jsx-a11y/recommended"], 4 | "settings": { 5 | "import/resolver": { 6 | "webpack": { 7 | "config": "config/webpack.config.babel.js" 8 | } 9 | } 10 | }, 11 | "rules": { 12 | "class-methods-use-this": "off", 13 | // Prettier has issues with this. 14 | // Remove this rule once https://github.com/prettier/prettier/issues/1565 is resolved 15 | "newline-per-chained-call": "off", 16 | // We don't do proptype validation in this example 17 | "react/prop-types": "off", 18 | "react/no-unknown-property": [ 19 | "error", 20 | { 21 | "ignore": ["class"] 22 | } 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /universal/app/assets/fonts/latoLatinLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/latoLatinLight.ttf -------------------------------------------------------------------------------- /universal/app/assets/fonts/latoLatinLight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/latoLatinLight.woff -------------------------------------------------------------------------------- /universal/app/assets/fonts/latoLatinLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/latoLatinLight.woff2 -------------------------------------------------------------------------------- /universal/app/assets/fonts/nexaExtraboldWebfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/nexaExtraboldWebfont.ttf -------------------------------------------------------------------------------- /universal/app/assets/fonts/nexaExtraboldWebfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/nexaExtraboldWebfont.woff -------------------------------------------------------------------------------- /universal/app/assets/fonts/nexaExtraboldWebfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/nexaExtraboldWebfont.woff2 -------------------------------------------------------------------------------- /universal/app/assets/fonts/nexaHeavyWebfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/nexaHeavyWebfont.ttf -------------------------------------------------------------------------------- /universal/app/assets/fonts/nexaHeavyWebfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/nexaHeavyWebfont.woff -------------------------------------------------------------------------------- /universal/app/assets/fonts/nexaHeavyWebfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/fonts/nexaHeavyWebfont.woff2 -------------------------------------------------------------------------------- /universal/app/assets/img/peerigonLogoMint.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /universal/app/assets/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/public/favicon.ico -------------------------------------------------------------------------------- /universal/app/assets/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /universal/app/assets/public/postImage1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/public/postImage1.jpg -------------------------------------------------------------------------------- /universal/app/assets/public/postImage2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/public/postImage2.jpg -------------------------------------------------------------------------------- /universal/app/assets/public/postImage3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/public/postImage3.jpg -------------------------------------------------------------------------------- /universal/app/assets/public/postImage4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/public/postImage4.jpg -------------------------------------------------------------------------------- /universal/app/assets/public/userImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/assets/public/userImage.jpg -------------------------------------------------------------------------------- /universal/app/client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /universal/app/client/captureFormSubmit.js: -------------------------------------------------------------------------------- 1 | import { state as routerState } from "../components/router/router"; 2 | 3 | const toArray = Array.from.bind(Array); 4 | const formDataFilter = ["_method", "_csrf"]; 5 | 6 | function collectFormData(formElement) { 7 | const formData = {}; 8 | 9 | toArray(formElement.elements) 10 | .filter(({ name }) => name !== "" && formDataFilter.indexOf(name) === -1) 11 | .forEach(({ name, value }) => { 12 | formData[name] = value; 13 | }); 14 | 15 | return formData; 16 | } 17 | 18 | export default function captureFormSubmit(store) { 19 | document.addEventListener("submit", event => { 20 | const formElement = event.target; 21 | 22 | event.preventDefault(); 23 | 24 | store.dispatch( 25 | routerState.actions.push({ 26 | method: formElement.elements._method.value, 27 | url: formElement.action, 28 | body: collectFormData(formElement), 29 | }) 30 | ); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /universal/app/client/captureHistoryPop.js: -------------------------------------------------------------------------------- 1 | import onHistoryPop from "nanohistory"; 2 | import { state as routerState } from "../components/router/router"; 3 | 4 | export default function captureHistoryPop(store) { 5 | onHistoryPop(location => { 6 | store.dispatch(routerState.actions.enter(location.href)); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /universal/app/client/captureLinkClick.js: -------------------------------------------------------------------------------- 1 | import onLinkClick from "nanohref"; 2 | import { state as routerState } from "../components/router/router"; 3 | 4 | export default function captureLinkClick(store) { 5 | onLinkClick(node => { 6 | const url = node.href; 7 | 8 | if (node.hasAttribute("data-route") === false) { 9 | window.location = url; 10 | 11 | return; 12 | } 13 | 14 | const action = routerState.actions[node.hasAttribute("data-replace-url") ? "replace" : "push"]; 15 | 16 | store.dispatch(action(url)); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /universal/app/client/index.js: -------------------------------------------------------------------------------- 1 | import { render, h } from "preact"; 2 | 3 | window.h = h; 4 | 5 | function startApp() { 6 | const createApp = require("../createApp").default; 7 | const captureFormSubmit = require("./captureFormSubmit").default; 8 | const captureLinkClick = require("./captureLinkClick").default; 9 | const captureHistoryPop = require("./captureHistoryPop").default; 10 | const preloadChunkEntries = require("./preloadChunkEntries").default; 11 | const chunkState = require("../components/chunks/chunks").state; 12 | const storeState = require("../components/store/store").state; 13 | 14 | const initialState = window.__PRELOADED_STATE__ || {}; 15 | const effectContext = {}; 16 | const { app, store } = createApp(initialState, effectContext); 17 | 18 | store.dispatch(storeState.actions.hydrateStates()); 19 | 20 | preloadChunkEntries(chunkState.select(store.getState()).loadedEntries).then(() => { 21 | render(app, document.body, document.body.firstElementChild); 22 | 23 | captureLinkClick(store); 24 | captureFormSubmit(store); 25 | captureHistoryPop(store); 26 | }); 27 | } 28 | 29 | document.addEventListener("DOMContentLoaded", startApp); 30 | -------------------------------------------------------------------------------- /universal/app/client/preloadChunkEntries.js: -------------------------------------------------------------------------------- 1 | export default function preloadChunkEntries(chunkEntries) { 2 | return Promise.all(chunkEntries.map(entry => entry.load())); 3 | } 4 | -------------------------------------------------------------------------------- /universal/app/components/about/about.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { rem } from "../../styles/scales"; 3 | import sheet from "../../styles/block/sheet"; 4 | import { 5 | regularFontSize, 6 | regularLineHeight, 7 | regularMaxWidth, 8 | headlineFontSize, 9 | headlineLineHeight, 10 | } from "../../styles/typoSizes"; 11 | import nexaHeavy from "../../styles/type/nexaHeavy"; 12 | import latoLight from "../../styles/type/latoLight"; 13 | 14 | export const root = css({ 15 | position: "relative", 16 | }); 17 | 18 | export const aboutSheet = css({ 19 | ...sheet, 20 | maxWidth: regularMaxWidth + "rem", 21 | marginLeft: "auto", 22 | marginRight: "auto", 23 | }); 24 | 25 | export const headline = css({ 26 | ...nexaHeavy, 27 | fontSize: headlineFontSize + "rem", 28 | lineHeight: headlineLineHeight + "rem", 29 | ":not(:last-child)": { 30 | marginBottom: rem(10) + "rem", 31 | }, 32 | }); 33 | 34 | export const text = css({ 35 | ...latoLight, 36 | fontSize: regularFontSize + "rem", 37 | lineHeight: regularLineHeight + "rem", 38 | }); 39 | -------------------------------------------------------------------------------- /universal/app/components/about/about.js: -------------------------------------------------------------------------------- 1 | import defineState from "../../store/defineState"; 2 | import contexts from "../../contexts"; 3 | import { aboutSheet, headline, text } from "./about.css"; 4 | 5 | const name = "about"; 6 | 7 | export const state = defineState({ 8 | scope: name, 9 | context: contexts.state, 10 | actions: { 11 | enter: () => (getState, patchState, dispatchAction) => {}, 12 | }, 13 | }); 14 | 15 | export default function About() { 16 | return ( 17 |
18 |
19 |

About

20 |
21 |

22 | Delectus quia nulla sit ex ipsum sit animi incidunt. Nam rerum reiciendis et. Minus voluptatem 23 | natus mollitia temporibus. Molestias dolorem omnis eveniet repudiandae corporis voluptas sed 24 | quo. 25 |

26 |

27 | Quisquam a vel quia in quis blanditiis sed. Labore ratione minus. A quo consequuntur recusandae 28 | consequatur. Et aspernatur quod officia rem quam nisi vel est quidem. 29 |

30 | 31 |

32 | Alias et fugit error quaerat consequatur. Voluptatem omnis aut voluptatem. Et necessitatibus qui 33 | voluptatem. 34 |

35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /universal/app/components/allPosts/allPosts.js: -------------------------------------------------------------------------------- 1 | import defineState from "../../store/defineState"; 2 | import contexts from "../../contexts"; 3 | import defineComponent from "../util/defineComponent"; 4 | import getAll from "../../effects/api/posts/getAll"; 5 | import Posts from "../posts/posts"; 6 | 7 | const name = "allPosts"; 8 | 9 | export const state = defineState({ 10 | scope: name, 11 | context: contexts.state, 12 | initialState: { 13 | posts: null, 14 | }, 15 | actions: { 16 | enter: () => (getState, patchState, dispatchAction, execEffect) => 17 | execEffect(getAll).then(posts => { 18 | patchState({ 19 | posts, 20 | }); 21 | }), 22 | update: () => (getState, patchState, dispatchAction, execEffect) => Function.prototype, 23 | }, 24 | }); 25 | 26 | export default defineComponent({ 27 | name, 28 | connectToStore: { 29 | watch: [state.select], 30 | }, 31 | render(props, state) { 32 | return ; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /universal/app/components/app/app.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { mintLight35, silverLight10, black } from "../../styles/colors"; 3 | import { linear } from "../../styles/gradients"; 4 | import { maxContentWidth } from "../../styles/layout"; 5 | import { paddingRegular } from "../../styles/paddings"; 6 | 7 | import "../../styles/reset"; // eslint-disable-line import/no-unassigned-import 8 | 9 | export const root = css({ 10 | margin: 0, 11 | color: black(), 12 | backgroundImage: linear("to bottom", [silverLight10(), mintLight35() + " 70vh"]), 13 | minHeight: "100vh", 14 | }); 15 | 16 | export const main = css({ 17 | maxWidth: maxContentWidth + "rem", 18 | marginLeft: "auto", 19 | marginRight: "auto", 20 | ["@media (min-width: " + paddingRegular * 20 + "px)"]: { 21 | padding: paddingRegular, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /universal/app/components/app/app.js: -------------------------------------------------------------------------------- 1 | import { root, main } from "./app.css"; 2 | import Header from "../header/header"; 3 | import Store from "../store/store"; 4 | import RoutePlaceholder from "../router/routePlaceholder"; 5 | import Modal from "../modal/modal"; 6 | 7 | export default function App(props) { 8 | return ( 9 | 10 |
11 |
12 |
13 | 14 |
15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /universal/app/components/chunks/chunks.js: -------------------------------------------------------------------------------- 1 | import defineState from "../../store/defineState"; 2 | import { state as storeState } from "../store/store"; 3 | import contexts from "../../contexts"; 4 | import renderChild from "../util/renderChild"; 5 | 6 | const name = "chunks"; 7 | 8 | function addIfNecessary(chunkEntry, getState, patchState, dispatchAction) { 9 | const loadedEntries = getState().loadedEntries; 10 | 11 | if (loadedEntries.indexOf(chunkEntry) === -1) { 12 | patchState({ 13 | loadedEntries: loadedEntries.concat(chunkEntry), 14 | }); 15 | 16 | dispatchAction(storeState.actions.hydrateStates()); 17 | } 18 | } 19 | 20 | export function selectLoadedChunks(contextState) { 21 | return state.select(contextState).loadedEntries.map(entries => entries.chunk); 22 | } 23 | 24 | export const state = defineState({ 25 | scope: name, 26 | context: contexts.state, 27 | initialState: { 28 | loadedEntries: [], 29 | }, 30 | hydrate(dehydrated) { 31 | return { 32 | ...dehydrated, 33 | loadedEntries: dehydrated.loadedEntries.map(id => contexts.chunkEntries[id]), 34 | toJSON() { 35 | return { 36 | ...this, 37 | loadedEntries: this.loadedEntries.map(entry => entry.id), 38 | }; 39 | }, 40 | }; 41 | }, 42 | actions: { 43 | import: chunkEntry => (getState, patchState, dispatchAction) => { 44 | const entryModule = chunkEntry.get(); 45 | 46 | if (entryModule !== null) { 47 | // In case the entryModule is already loaded into the application, 48 | // we need to add it synchronously to the store because the import action 49 | // might have been triggered during a server render. 50 | addIfNecessary(chunkEntry, getState, patchState, dispatchAction); 51 | 52 | return Promise.resolve(entryModule); 53 | } 54 | 55 | return chunkEntry 56 | .load() 57 | .then(() => addIfNecessary(chunkEntry, getState, patchState, dispatchAction)) 58 | .then(() => chunkEntry.get()); 59 | }, 60 | }, 61 | }); 62 | 63 | export default renderChild; 64 | -------------------------------------------------------------------------------- /universal/app/components/chunks/defineChunkEntry.js: -------------------------------------------------------------------------------- 1 | import { state as chunkState } from "./chunks"; 2 | import Placeholder from "../placeholder/placeholder"; 3 | import defineComponent from "../util/defineComponent"; 4 | import has from "../../util/has"; 5 | 6 | export default function defineChunkEntry(descriptor) { 7 | const chunk = descriptor.chunk; 8 | const context = descriptor.context; 9 | 10 | if (typeof chunk !== "string") { 11 | throw new Error("Chunk name is missing"); 12 | } 13 | if (context === undefined) { 14 | throw new Error("Chunk entry context is missing"); 15 | } 16 | 17 | const id = has(descriptor, "name") ? descriptor.chunk + "/" + descriptor.name : descriptor.chunk; 18 | 19 | if (has(context, id)) { 20 | throw new Error(`Chunk entry ${ id } is already defined on given chunk entry context`); 21 | } 22 | 23 | const load = descriptor.load; 24 | let entryModule = null; 25 | let error = null; 26 | const ChunkEntryPlaceholder = defineComponent({ 27 | connectToStore: { 28 | watch: [chunkState.select], 29 | mapToState: () => ({ 30 | Component: entryModule === null ? error : entryModule.default, 31 | }), 32 | }, 33 | render(props, state) { 34 | if (descriptor.placeholder) { 35 | return descriptor.placeholder(props, state); 36 | } 37 | 38 | return ; 39 | }, 40 | }); 41 | const chunkEntry = { 42 | id, 43 | chunk, 44 | get: () => entryModule, 45 | load: () => { 46 | if (entryModule !== null) { 47 | return Promise.resolve(entryModule); 48 | } 49 | 50 | return load().then( 51 | result => { 52 | error = null; 53 | entryModule = result; 54 | 55 | return result; 56 | }, 57 | err => { 58 | error = err; 59 | entryModule = null; 60 | 61 | throw err; 62 | } 63 | ); 64 | }, 65 | Placeholder: ChunkEntryPlaceholder, 66 | }; 67 | 68 | chunkEntry.import = chunkState.actions.import(chunkEntry); 69 | 70 | context[id] = chunkEntry; 71 | 72 | return chunkEntry; 73 | } 74 | -------------------------------------------------------------------------------- /universal/app/components/document/document.js: -------------------------------------------------------------------------------- 1 | import contexts from "../../contexts"; 2 | import defineState from "../../store/defineState"; 3 | import renderChild from "../util/renderChild"; 4 | import document from "../../effects/document"; 5 | 6 | export const state = defineState({ 7 | scope: "document", 8 | context: contexts.state, 9 | initialState: { 10 | statusCode: null, 11 | title: null, 12 | headerTags: null, 13 | }, 14 | actions: { 15 | update: state => (getState, patchState, dispatchAction, execEffect) => { 16 | patchState(state); 17 | 18 | const newState = getState(); 19 | 20 | execEffect(document.setTitle, newState.title); 21 | }, 22 | }, 23 | }); 24 | 25 | export default renderChild; 26 | -------------------------------------------------------------------------------- /universal/app/components/error/error.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { rem } from "../../styles/scales"; 3 | import sheet from "../../styles/block/sheet"; 4 | import { 5 | regularFontSize, 6 | regularLineHeight, 7 | regularMaxWidth, 8 | headlineFontSize, 9 | headlineLineHeight, 10 | } from "../../styles/typoSizes"; 11 | import nexaHeavy from "../../styles/type/nexaHeavy"; 12 | import latoLight from "../../styles/type/latoLight"; 13 | 14 | export const root = css({ 15 | position: "relative", 16 | }); 17 | 18 | export const errorSheet = css({ 19 | ...sheet, 20 | maxWidth: regularMaxWidth + "rem", 21 | marginLeft: "auto", 22 | marginRight: "auto", 23 | }); 24 | 25 | export const headline = css({ 26 | ...nexaHeavy, 27 | fontSize: headlineFontSize + "rem", 28 | lineHeight: headlineLineHeight + "rem", 29 | ":not(:last-child)": { 30 | marginBottom: rem(10) + "rem", 31 | }, 32 | }); 33 | 34 | export const text = css({ 35 | ...latoLight, 36 | fontSize: regularFontSize + "rem", 37 | lineHeight: regularLineHeight + "rem", 38 | }); 39 | -------------------------------------------------------------------------------- /universal/app/components/error/error.js: -------------------------------------------------------------------------------- 1 | import defineState from "../../store/defineState"; 2 | import defineComponent from "../util/defineComponent"; 3 | import contexts from "../../contexts"; 4 | import { errorSheet, headline, text } from "./error.css"; 5 | import has from "../../util/has"; 6 | 7 | const name = "error"; 8 | 9 | export const state = defineState({ 10 | scope: name, 11 | context: contexts.state, 12 | initialState: { 13 | headline: null, 14 | message: null, 15 | }, 16 | actions: { 17 | enter: (request, route, params) => (getState, patchState, dispatchAction) => { 18 | const headline = has(params, "title") ? params.title : "Error"; 19 | const message = has(params, "message") ? params.message : "An unexpected error occurred"; 20 | 21 | patchState({ 22 | headline, 23 | message, 24 | }); 25 | }, 26 | }, 27 | }); 28 | 29 | export default defineComponent({ 30 | name, 31 | connectToStore: { 32 | watch: [state.select], 33 | }, 34 | render(props, state) { 35 | return ( 36 |
37 |
38 |

39 | {state.headline} 40 |

41 |
42 |

43 | {state.message} 44 |

45 |
46 |
47 |
48 | ); 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /universal/app/components/form/form.js: -------------------------------------------------------------------------------- 1 | import has from "../../util/has"; 2 | import filterProps from "../../util/filterProps"; 3 | import renderUrl from "../../util/renderUrl"; 4 | import defineComponent from "../util/defineComponent"; 5 | import { state as routerState } from "../router/router"; 6 | 7 | const name = "form"; 8 | const ownProps = ["method", "csrfToken", "actionRoute", "actionParams"]; 9 | const filterDangerousParams = ["next", "previous", "form"]; 10 | const emptyObj = {}; 11 | 12 | export default defineComponent({ 13 | name, 14 | connectToStore: { 15 | watch: [routerState.select], 16 | mapToState: ({ request }) => ({ 17 | currentUrl: request.url, 18 | }), 19 | }, 20 | render(props, state) { 21 | const { actionParams = emptyObj } = props; 22 | const method = has(props, "method") ? props.method.toUpperCase() : "GET"; 23 | const formProps = filterProps(props, ownProps); 24 | const isNonGET = method !== "GET"; 25 | // Do not extend the action params with pervious and next parameters 26 | // when it's a GET request because these requests can be crafted by an attacker. 27 | // All the other requests require a valid csrf token. As soon as the request 28 | // hits our route handler, we can expect it to be safe. 29 | const extendedActionParams = isNonGET ? 30 | { 31 | ...actionParams, 32 | previous: has(actionParams, "previous") ? actionParams.previous : state.currentUrl, 33 | next: has(actionParams, "next") ? actionParams.next : state.currentUrl, 34 | form: has(props, "name") ? props.name : null, 35 | } : 36 | filterProps(actionParams, filterDangerousParams); 37 | 38 | // HTML forms only support GET and POST 39 | // The actual method is encoded as _method param 40 | return ( 41 |
46 | 47 | {has(props, "csrfToken") ? : null} 48 | {props.children} 49 |
50 | ); 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /universal/app/components/formFeedback/formFeedback.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { asideFontSize, asideLineHeight } from "../../styles/typoSizes"; 3 | import { red } from "../../styles/colors"; 4 | import latoLight from "../../styles/type/latoLight"; 5 | import { msToSeconds } from "../../styles/timing"; 6 | 7 | export const transitionDuration = 100; 8 | 9 | const transitionDurationCss = msToSeconds(100) + "s"; 10 | 11 | export const overflowContainer = css({ 12 | display: "inline-block", 13 | overflow: "hidden", 14 | transition: `height ${ transitionDurationCss } ease-in-out`, 15 | }); 16 | 17 | export const message = css({ 18 | ...latoLight, 19 | display: "inline-block", 20 | color: red(), 21 | fontSize: asideFontSize + "rem", 22 | lineHeight: asideLineHeight + "rem", 23 | minHeight: asideLineHeight + "rem", 24 | transform: "translateY(0)", 25 | transition: `transform ${ transitionDurationCss } ease-in-out`, 26 | ":empty": { 27 | transform: "translateY(-100%)", 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /universal/app/components/formFeedback/formFeedback.js: -------------------------------------------------------------------------------- 1 | import { overflowContainer, message } from "./formFeedback.css"; 2 | 3 | export default function FormFeedback(props) { 4 | const styles = { ...overflowContainer, ...props }; 5 | 6 | return ( 7 | 8 | 9 | {props.children} 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /universal/app/components/header/common.js: -------------------------------------------------------------------------------- 1 | import { rem } from "../../styles/scales"; 2 | 3 | // The type looks more vertically centered with this offset 4 | export const logoHeight = rem(17); 5 | export const headerCollapseBreakpoint = "@media (max-width: 35rem)"; 6 | -------------------------------------------------------------------------------- /universal/app/components/header/header.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { white, black } from "../../styles/colors"; 3 | import { px, rem } from "../../styles/scales"; 4 | import nexaHeavy from "../../styles/type/nexaHeavy"; 5 | import { maxContentWidth } from "../../styles/layout"; 6 | import { offscreen as a11yOffscreen } from "../../styles/a11y"; 7 | import { header as headerZIndex } from "../../styles/zIndex"; 8 | import { logoHeight, headerCollapseBreakpoint } from "./common"; 9 | 10 | export const root = css({ 11 | position: "sticky", 12 | top: 0, 13 | zIndex: headerZIndex, 14 | color: black(), 15 | backgroundColor: white(), 16 | boxShadow: "0 5px 5px rgba(0, 0, 0, 0.1)", 17 | }); 18 | 19 | export const content = css({ 20 | display: "flex", 21 | alignItems: "center", 22 | lineHeight: logoHeight + "rem", 23 | flexWrap: "wrap", 24 | padding: px(6), 25 | maxWidth: maxContentWidth + "rem", 26 | marginLeft: "auto", 27 | marginRight: "auto", 28 | }); 29 | 30 | export const logo = css({ 31 | display: "flex", 32 | alignItems: "center", 33 | textDecoration: "none", 34 | color: "currentColor", 35 | }); 36 | 37 | export const nav = css({ 38 | marginLeft: rem(12) + "rem", 39 | [headerCollapseBreakpoint]: { 40 | marginLeft: 0, 41 | width: "100%", 42 | order: 1, 43 | }, 44 | }); 45 | 46 | export const session = css({ 47 | marginLeft: "auto", 48 | }); 49 | 50 | export const headline = css({ 51 | ...nexaHeavy, 52 | fontSize: rem(13) + "rem", 53 | margin: 0, 54 | marginLeft: px(10), 55 | }); 56 | 57 | export const offscreen = css(a11yOffscreen); 58 | -------------------------------------------------------------------------------- /universal/app/components/header/header.js: -------------------------------------------------------------------------------- 1 | import Logo from "./logo/logo"; 2 | import Nav from "./nav/nav"; 3 | import Link from "../router/link"; 4 | import routes from "../../routes"; 5 | import { root, content, logo, nav, headline, session, offscreen } from "./header.css"; 6 | import defineComponent from "../util/defineComponent"; 7 | import Session from "./session/session"; 8 | 9 | export default defineComponent({ 10 | name: "Header", 11 | render() { 12 | return ( 13 |
14 |
15 | 16 | 17 |

18 | Peerigon News 19 |

20 | 21 |
24 |
25 | ); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /universal/app/components/header/link.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px, rem } from "../../styles/scales"; 3 | import nexaXBold from "../../styles/type/nexaXBold"; 4 | import { regular as regularBorder } from "../../styles/borders"; 5 | 6 | const activeLinkStyles = { 7 | borderTop: regularBorder("transparent"), 8 | borderBottom: regularBorder(), 9 | }; 10 | 11 | export const activeLink = css(activeLinkStyles); 12 | 13 | export const link = css({ 14 | ...nexaXBold, 15 | // There's a small baseline correction necessary 16 | position: "relative", 17 | top: 1, 18 | color: "currentColor", 19 | fontSize: rem(12) + "rem", 20 | textDecoration: "none", 21 | padding: `2px ${ px(5) }px`, 22 | cursor: "pointer", 23 | ":hover": activeLinkStyles, 24 | ":active": activeLinkStyles, 25 | }); 26 | -------------------------------------------------------------------------------- /universal/app/components/header/logo/logo.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { logoHeight } from "../common"; 3 | 4 | export const logoImg = css({ 5 | position: "relative", 6 | display: "block", 7 | height: logoHeight + "rem", 8 | }); 9 | -------------------------------------------------------------------------------- /universal/app/components/header/logo/logo.js: -------------------------------------------------------------------------------- 1 | import logoSrc from "../../../assets/img/peerigonLogoMint.svg"; 2 | import { logoImg } from "./logo.css"; 3 | 4 | export default function Logo() { 5 | return ( 6 | {"Peerigon 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /universal/app/components/header/nav/nav.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px } from "../../../styles/scales"; 3 | 4 | export const list = css({ 5 | display: "flex", 6 | listStyleType: "none", 7 | }); 8 | 9 | export const listItem = css({ 10 | ":not(:last-child)": { 11 | marginRight: px(10), 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /universal/app/components/header/nav/nav.js: -------------------------------------------------------------------------------- 1 | import Link from "../../router/link"; 2 | import { list, listItem } from "./nav.css"; 3 | import { link, activeLink } from "../link.css"; 4 | import routes from "../../../routes"; 5 | import { nbsp } from "../../../util/htmlEntities"; 6 | 7 | export default function Nav(props) { 8 | return ( 9 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /universal/app/components/header/session/anonymous/anonymous.js: -------------------------------------------------------------------------------- 1 | import defineComponent from "../../../util/defineComponent"; 2 | import { state as routerState } from "../../../router/router"; 3 | import { link } from "../../link.css"; 4 | import { nbsp } from "../../../../util/htmlEntities"; 5 | import ModalLink from "../../../modal/modalLink"; 6 | import loginForm from "../../../loginForm"; 7 | import renderUrl from "../../../../util/renderUrl"; 8 | import filterProps from "../../../../util/filterProps"; 9 | 10 | const name = "headerSessionAnonymous"; 11 | const LoginFormPlaceholder = loginForm.Placeholder; 12 | const emptyObj = {}; 13 | const triggerParam = "showLogin"; 14 | 15 | export default defineComponent({ 16 | name, 17 | connectToStore: { 18 | watch: [routerState.select], 19 | mapToState: ({ request, route, params }) => ({ 20 | nextUrlAfterLogin: renderUrl(route.url, filterProps(params, [triggerParam])), 21 | }), 22 | }, 23 | render(props, state) { 24 | return ( 25 |
26 | } 28 | triggerParam={triggerParam} 29 | importAction={loginForm.import} 30 | {...link} 31 | > 32 | Log{nbsp}in 33 | 34 |
35 | ); 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /universal/app/components/header/session/personal/personal.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px } from "../../../../styles/scales"; 3 | import latoLight from "../../../../styles/type/latoLight"; 4 | import { headerCollapseBreakpoint, logoHeight } from "../../common"; 5 | import { regularFontSize } from "../../../../styles/typoSizes"; 6 | 7 | export const root = css({ 8 | display: "flex", 9 | alignItems: "center", 10 | fontSize: regularFontSize + "rem", 11 | "> *:not(:last-child)": { 12 | marginRight: px(10), 13 | }, 14 | }); 15 | 16 | export const userName = css({ 17 | ...latoLight, 18 | [headerCollapseBreakpoint]: { 19 | display: "none", 20 | }, 21 | }); 22 | 23 | export const userImage = css({ 24 | display: "block", 25 | height: logoHeight + "rem", 26 | borderRadius: "100%", 27 | }); 28 | -------------------------------------------------------------------------------- /universal/app/components/header/session/personal/personal.js: -------------------------------------------------------------------------------- 1 | import { root, userName, userImage } from "./personal.css"; 2 | import { link } from "../../link.css"; 3 | import defineForm from "../../../form/defineForm"; 4 | import defineComponent from "../../../util/defineComponent"; 5 | import Form from "../../../form/form"; 6 | import contexts from "../../../../contexts"; 7 | import routes from "../../../../routes"; 8 | 9 | const logoutForm = defineForm({ 10 | name: "logoutForm", 11 | context: contexts.state, 12 | }); 13 | 14 | const LogoutForm = defineComponent({ 15 | name: "LogoutForm", 16 | connectToStore: { 17 | watch: [logoutForm.select], 18 | mapToState: ({ csrfToken }) => ({ 19 | csrfToken, 20 | }), 21 | }, 22 | render(props, { csrfToken }) { 23 | return ( 24 |
25 | 26 |
27 | ); 28 | }, 29 | }); 30 | 31 | export default function HeaderSessionPersonal(props) { 32 | const user = props.user; 33 | 34 | if (user === null) { 35 | return null; 36 | } 37 | 38 | return ( 39 |
40 | {user.name} 41 | 42 | {user.name} 43 | 44 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /universal/app/components/header/session/session.js: -------------------------------------------------------------------------------- 1 | import { state as sessionState } from "../../session/session"; 2 | import Personal from "./personal/personal"; 3 | import Anonymous from "./anonymous/anonymous"; 4 | import defineComponent from "../../util/defineComponent"; 5 | import has from "../../../util/has"; 6 | 7 | const name = "headerSession"; 8 | const emptyObj = {}; 9 | 10 | export default defineComponent({ 11 | name, 12 | connectToStore: { 13 | watch: [sessionState.select], 14 | mapToState: ({ user }) => ({ 15 | user, 16 | isLoggedIn: user !== null, 17 | }), 18 | }, 19 | render(props, state) { 20 | const user = state.user; 21 | const styles = has(props, "styles") ? props.styles : emptyObj; 22 | 23 | return ( 24 |
25 | {state.isLoggedIn ? : } 26 |
27 | ); 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /universal/app/components/loading/loading.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import latoLight from "../../styles/type/latoLight"; 3 | import { rem } from "../../styles/scales"; 4 | 5 | export const root = css({ 6 | ...latoLight, 7 | fontSize: rem(13) + "rem", 8 | display: "flex", 9 | width: "100%", 10 | height: "100%", 11 | minHeight: rem(20) + "rem", 12 | alignItems: "center", 13 | justifyContent: "center", 14 | }); 15 | -------------------------------------------------------------------------------- /universal/app/components/loading/loading.js: -------------------------------------------------------------------------------- 1 | import { root } from "./loading.css"; 2 | 3 | export const loading =
Loading...
; 4 | 5 | export default function Loading() { 6 | return loading; 7 | } 8 | -------------------------------------------------------------------------------- /universal/app/components/loginForm/index.js: -------------------------------------------------------------------------------- 1 | import defineChunkEntry from "../chunks/defineChunkEntry"; 2 | import contexts from "../../contexts"; 3 | 4 | export default defineChunkEntry({ 5 | chunk: "session", 6 | name: "loginForm", 7 | context: contexts.chunkEntries, 8 | load: () => import("./loginForm" /* webpackChunkName: "session" */), 9 | }); 10 | -------------------------------------------------------------------------------- /universal/app/components/loginForm/loginForm.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { rem } from "../../styles/scales"; 3 | import sheet from "../../styles/block/sheet"; 4 | import { regularFontSize, regularLineHeight } from "../../styles/typoSizes"; 5 | import latoLight from "../../styles/type/latoLight"; 6 | import inputText from "../../styles/block/inputText"; 7 | import inputSubmit from "../../styles/block/inputSubmit"; 8 | import { paddingBigger } from "../../styles/paddings"; 9 | 10 | export const loginSheet = css({ 11 | ...sheet, 12 | ...latoLight, 13 | maxWidth: rem(29) + "rem", 14 | boxSizing: "border-box", 15 | padding: paddingBigger, 16 | fontSize: regularFontSize + "rem", 17 | lineHeight: regularLineHeight + "rem", 18 | display: "flex", 19 | flexDirection: "column", 20 | }); 21 | 22 | export const loginLabel = css({ 23 | ":after": { 24 | content: JSON.stringify(":"), 25 | }, 26 | }); 27 | 28 | export const loginInput = css({ 29 | ...inputText, 30 | }); 31 | 32 | export const formFeedback = css({ 33 | marginBottom: rem(10) + "rem", 34 | }); 35 | 36 | export const loginSubmit = css(inputSubmit); 37 | -------------------------------------------------------------------------------- /universal/app/components/loginForm/loginFormValidators.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: formData => { 3 | const name = formData.name; 4 | 5 | if (name === "") { 6 | return "Missing login name"; 7 | } 8 | 9 | return null; 10 | }, 11 | password: formData => { 12 | const password = formData.password; 13 | 14 | if (password === "") { 15 | return "Missing password"; 16 | } 17 | 18 | return null; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /universal/app/components/modal/modal.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import hexToRgba from "hex-to-rgba"; 3 | import { backdrop as backdropZIndex, modal as modalZIndex } from "../../styles/zIndex"; 4 | import { msToSeconds } from "../../styles/timing"; 5 | import { white, mint } from "../../styles/colors"; 6 | import { paddingRegular } from "../../styles/paddings"; 7 | import calc from "../../styles/calc"; 8 | 9 | export const fadeDuration = 100; 10 | const fadeDurationCss = msToSeconds(fadeDuration) + "s"; 11 | const backdropOpacity = 0.4; 12 | 13 | const backdropGlowAnimation = css.keyframes({ 14 | "50%": { 15 | backgroundColor: hexToRgba(mint(), "0.2"), 16 | }, 17 | }); 18 | 19 | export const root = css({ 20 | position: "absolute", 21 | top: 0, 22 | width: "100vw", 23 | height: "100vh", 24 | }); 25 | 26 | export const rootHidden = css({ 27 | transition: `transform 0s ${ fadeDurationCss }`, 28 | transform: "translateY(-100%)", 29 | }); 30 | 31 | export const rootVisible = css({}); 32 | 33 | export const backdrop = css({ 34 | position: "fixed", 35 | top: 0, 36 | left: 0, 37 | right: 0, 38 | bottom: 0, 39 | zIndex: backdropZIndex, 40 | backgroundColor: "black", 41 | ":focus": { 42 | animation: `${ backdropGlowAnimation } infinite 3s ease-in-out`, 43 | }, 44 | }); 45 | 46 | export const backdropHidden = css({ 47 | opacity: 0, 48 | transition: `opacity ${ fadeDurationCss } ease-in-out, transform 0s ${ fadeDurationCss }`, 49 | transform: "translateY(-100%)", 50 | }); 51 | 52 | export const backdropVisible = css({ 53 | opacity: backdropOpacity, 54 | transition: `opacity ${ fadeDurationCss } ease-in-out`, 55 | }); 56 | 57 | export const window = css({ 58 | position: "fixed", 59 | zIndex: modalZIndex, 60 | top: "50%", 61 | left: "50%", 62 | transform: "translate(-50%, -50%)", 63 | backgroundColor: white(), 64 | boxShadow: "0 7px 7px rgba(0, 0, 0, 0.3)", 65 | "> *": { 66 | width: calc("100vw - ", paddingRegular * 2, "px"), 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /universal/app/components/modal/modal.js: -------------------------------------------------------------------------------- 1 | import defineComponent from "../util/defineComponent"; 2 | import contexts from "../../contexts"; 3 | import defineState from "../../store/defineState"; 4 | import Link from "../router/link"; 5 | import { root, rootVisible, rootHidden, window, backdrop, backdropVisible, backdropHidden } from "./modal.css"; 6 | 7 | const name = "modal"; 8 | 9 | function isCurrentlyActive(state, component) { 10 | const current = state.component; 11 | 12 | return current !== null && current === component; 13 | } 14 | 15 | export const state = defineState({ 16 | scope: name, 17 | context: contexts.state, 18 | initialState: { 19 | component: null, 20 | backUrl: "", 21 | }, 22 | hydrate(dehydrated) { 23 | return { 24 | ...dehydrated, 25 | toJSON: () => undefined, 26 | }; 27 | }, 28 | actions: { 29 | show: (component, backUrl = "") => (getState, patchState, dispatchAction) => { 30 | patchState({ 31 | component, 32 | backUrl, 33 | }); 34 | }, 35 | hide: component => (getState, patchState, dispatchAction) => { 36 | if (isCurrentlyActive(getState(), component) === true) { 37 | patchState({ 38 | component: null, 39 | backUrl: "", 40 | }); 41 | } 42 | }, 43 | }, 44 | }); 45 | 46 | export default defineComponent({ 47 | name, 48 | connectToStore: { 49 | watch: [state.select], 50 | mapToState: ({ component, backUrl }, oldState) => ({ 51 | active: component !== null, 52 | component, 53 | backUrl, 54 | }), 55 | }, 56 | render(props, state) { 57 | const rootStyles = { 58 | ...root, 59 | ...(state.active === true ? rootVisible : rootHidden), 60 | }; 61 | const backdropStyles = { 62 | ...backdrop, 63 | ...(state.active === true ? backdropVisible : backdropHidden), 64 | }; 65 | 66 | return ( 67 |
68 | 69 |
70 | {state.component} 71 |
72 |
73 | ); 74 | }, 75 | }); 76 | -------------------------------------------------------------------------------- /universal/app/components/modal/modalLink.js: -------------------------------------------------------------------------------- 1 | import defineComponent from "../util/defineComponent"; 2 | import Link from "../router/link"; 3 | import ModalTrigger from "./modalTrigger"; 4 | import filterProps from "../../util/filterProps"; 5 | import { state as routerState } from "../router/router"; 6 | 7 | const name = "modalLink"; 8 | const ownProps = ["triggerParam", "importAction", "modal", "children"]; 9 | 10 | export default defineComponent({ 11 | name, 12 | connectToStore: { 13 | watch: [routerState.select], 14 | mapToState: ({ params }) => ({ 15 | params, 16 | }), 17 | }, 18 | render(props, state) { 19 | const linkProps = filterProps(props, ownProps); 20 | const additionalParams = Object.assign({}, state.params, props.additionalParams); 21 | 22 | additionalParams[props.triggerParam] = 1; 23 | 24 | return ( 25 | 26 | {props.children} 27 | 28 | {props.modal} 29 | 30 | 31 | ); 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /universal/app/components/modal/modalTrigger.js: -------------------------------------------------------------------------------- 1 | import defineComponent from "../util/defineComponent"; 2 | import { state as routerState } from "../router/router"; 3 | import { state as modalState } from "./modal"; 4 | import has from "../../util/has"; 5 | import renderUrl from "../../util/renderUrl"; 6 | import filterProps from "../../util/filterProps"; 7 | 8 | const name = "modalTrigger"; 9 | 10 | export default defineComponent({ 11 | name, 12 | connectToStore: { 13 | watch: [routerState.select], 14 | mapToState: ({ request, route, params }, { triggerParam }, oldState) => { 15 | const isErrorRoute = route.error === true; 16 | const skipStateChange = request.method !== "GET" && isErrorRoute === false; 17 | 18 | if (skipStateChange) { 19 | return oldState; 20 | } 21 | 22 | return { 23 | shouldBeActive: parseInt(params[triggerParam]) === 1, 24 | backUrl: renderUrl(route.url, filterProps(params, [triggerParam])), 25 | }; 26 | }, 27 | }, 28 | willUpdate(props, state, dispatchAction) { 29 | const childComponent = props.children[0]; 30 | 31 | if (state.shouldBeActive) { 32 | if (has(props, "importAction")) { 33 | dispatchAction(props.importAction); 34 | } 35 | dispatchAction(modalState.actions.show(childComponent, state.backUrl)); 36 | } else { 37 | dispatchAction(modalState.actions.hide(childComponent)); 38 | } 39 | }, 40 | render() { 41 | return null; 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /universal/app/components/placeholder/placeholder.js: -------------------------------------------------------------------------------- 1 | import Loading from "../loading/loading"; 2 | import defineComponent from "../util/defineComponent"; 3 | 4 | const name = "placeholder"; 5 | 6 | export default defineComponent({ 7 | name, 8 | render({ children, Component = null, props = {} }) { 9 | const noChildren = children.length === 0; 10 | 11 | if (Component === null && noChildren) { 12 | return ; 13 | } 14 | if (Component !== null && Component instanceof Error === false) { 15 | return ; 16 | } 17 | 18 | const err = Component; 19 | 20 | if (err !== null && noChildren) { 21 | return ( 22 |
23 | {Component.message} 24 |
25 | ); 26 | } 27 | 28 | const childGenerator = children[0]; 29 | 30 | return childGenerator(err); 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /universal/app/components/posts/post/post.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import nexaHeavy from "../../../styles/type/nexaHeavy"; 3 | import latoLight from "../../../styles/type/latoLight"; 4 | import { rem } from "../../../styles/scales"; 5 | import { 6 | regularFontSize, 7 | regularLineHeight, 8 | regularMaxWidth, 9 | headlineFontSize, 10 | headlineLineHeight, 11 | headlineMaxWidth, 12 | } from "../../../styles/typoSizes"; 13 | 14 | export const headline = css({ 15 | ...nexaHeavy, 16 | fontSize: headlineFontSize + "rem", 17 | lineHeight: headlineLineHeight + "rem", 18 | maxWidth: headlineMaxWidth + "rem", 19 | marginBottom: rem(6) + "rem", 20 | }); 21 | 22 | export const aside = css({ 23 | ...latoLight, 24 | display: "block", 25 | fontSize: rem(11) + "rem", 26 | lineHeight: rem(12) + "rem", 27 | marginBottom: rem(13) + "rem", 28 | }); 29 | 30 | export const paragraph = css({ 31 | ...latoLight, 32 | fontSize: regularFontSize + "rem", 33 | lineHeight: regularLineHeight + "rem", 34 | maxWidth: regularMaxWidth + "rem", 35 | ":not(:last-child)": { 36 | marginBottom: rem(10) + "rem", 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /universal/app/components/posts/post/post.js: -------------------------------------------------------------------------------- 1 | import fromNow from "from-now"; 2 | import defineComponent from "../../util/defineComponent"; 3 | import { headline, paragraph, aside } from "./post.css"; 4 | import has from "../../../util/has"; 5 | 6 | const name = "postsPost"; 7 | const lineBreak = /\s*[\r\n]+\s*/g; 8 | const emptyObj = {}; 9 | 10 | export default defineComponent({ 11 | name, 12 | render(props) { 13 | const post = props.post; 14 | const styles = has(props, "styles") ? props.styles : emptyObj; 15 | 16 | return ( 17 |
18 |

19 | {post.title} 20 |

21 |
22 | 25 | {" ago by "} 26 | {post.author} 27 |
28 |
29 | {post.content.split(lineBreak).map(p => 30 | (

31 | {p} 32 |

) 33 | )} 34 |
35 |
36 | ); 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /universal/app/components/posts/posts.css.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { px, rem } from "../../styles/scales"; 3 | import { offscreen } from "../../styles/a11y"; 4 | import { regularMaxWidth } from "../../styles/typoSizes"; 5 | import sheet, { sheetPadding } from "../../styles/block/sheet"; 6 | import attrSelector from "../util/attrSelector"; 7 | 8 | export const root = css({ 9 | position: "relative", 10 | }); 11 | 12 | export const a11yTitle = css({ 13 | ...offscreen, 14 | }); 15 | 16 | export const postImage = css({ 17 | position: "absolute", 18 | maxWidth: px(30), 19 | marginTop: sheetPadding, 20 | transform: "translate(0%)", 21 | transition: "transform 0.3s ease-in-out", 22 | }); 23 | 24 | export const postSheet = css({ 25 | ...sheet, 26 | // position relative is necessary so that the position absolute image is still below the sheet 27 | position: "relative", 28 | maxWidth: regularMaxWidth + "rem", 29 | }); 30 | 31 | export const postContainer = css({ 32 | position: "relative", 33 | overflow: "hidden", 34 | ":not(:last-child)": { 35 | marginBottom: rem(15) + "rem", 36 | }, 37 | [":nth-child(odd) " + attrSelector(postSheet)]: { 38 | marginLeft: "auto", 39 | }, 40 | [":nth-child(odd) " + attrSelector(postImage)]: { 41 | left: px(17), 42 | }, 43 | [":nth-child(even) " + attrSelector(postImage)]: { 44 | right: px(17), 45 | }, 46 | [":nth-child(odd):not(:hover) " + attrSelector(postImage)]: { 47 | transform: "translate(10%)", 48 | }, 49 | [":nth-child(even):not(:hover) " + attrSelector(postImage)]: { 50 | transform: "translate(-10%)", 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /universal/app/components/posts/posts.js: -------------------------------------------------------------------------------- 1 | import defineComponent from "../util/defineComponent"; 2 | import Post from "./post/post"; 3 | import { a11yTitle, root, postContainer, postSheet, postImage } from "./posts.css"; 4 | 5 | const name = "posts"; 6 | const empty = []; 7 | 8 | export default defineComponent({ 9 | name, 10 | render(props) { 11 | const posts = props.posts; 12 | 13 | return ( 14 |
15 |

16 | {props.a11yTitle} 17 |

18 | {Array.isArray(posts) === false || posts.length === 0 ? 19 | empty : 20 | posts.map(post => 21 | (
22 | {post.title} 23 | 24 |
) 25 | )} 26 |
27 | ); 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /universal/app/components/router/errors/methodNotAllowed.js: -------------------------------------------------------------------------------- 1 | export default function methodNotAllowed(allowedMethods, requestPathname) { 2 | return { 3 | statusCode: 405, 4 | title: "Method not allowed", 5 | message: `Only ${ allowedMethods.join(", ") } is allowed at ${ requestPathname }.`, 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /universal/app/components/router/link.js: -------------------------------------------------------------------------------- 1 | import hookIntoEvent from "../util/hookIntoEvent"; 2 | import renderUrl from "../../util/renderUrl"; 3 | import defineComponent from "../util/defineComponent"; 4 | import { state as routerState } from "./router"; 5 | import filterProps from "../../util/filterProps"; 6 | import has from "../../util/has"; 7 | 8 | const emptyObj = {}; 9 | const emptyArr = []; 10 | const ownProps = [ 11 | "route", 12 | "params", 13 | "children", 14 | "activeClass", 15 | "replaceRoute", 16 | "additionalParams", 17 | "withoutParams", 18 | "preloadAction", 19 | ]; 20 | 21 | function dispatchPreloadAction(dispatchAction, event, props, state) { 22 | dispatchAction(state.preloadAction); 23 | } 24 | 25 | export default defineComponent({ 26 | name: "Link", 27 | connectToStore: { 28 | watch: [routerState.select], 29 | mapToState: (routerState, props) => { 30 | const route = has(props, "route") ? props.route : routerState.route; 31 | 32 | return { 33 | url: routerState.request.url, 34 | route, 35 | params: routerState.params, 36 | isActive: route === routerState.route, 37 | preloadAction: has(props, "preloadAction") ? props.preloadAction : route.entry, 38 | }; 39 | }, 40 | }, 41 | handlers: { 42 | handleMouseOver: hookIntoEvent("onMouseOver", dispatchPreloadAction), 43 | handleFocus: hookIntoEvent("onFocus", dispatchPreloadAction), 44 | }, 45 | render(props, state) { 46 | const anchorProps = filterProps(props, ownProps); 47 | const { 48 | params = emptyObj, 49 | additionalParams, 50 | withoutParams = emptyArr, 51 | replaceRoute = false, 52 | href, 53 | children, 54 | activeClass, 55 | } = props; 56 | const route = state.route; 57 | let finalHref = href; 58 | 59 | if (href === undefined) { 60 | const finalParams = filterProps(Object.assign({}, params, additionalParams), withoutParams); 61 | 62 | finalHref = renderUrl(route.url, finalParams); 63 | } 64 | 65 | return ( 66 | 75 | {children} 76 | 77 | ); 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /universal/app/components/router/routePlaceholder.js: -------------------------------------------------------------------------------- 1 | import defineComponent from "../util/defineComponent"; 2 | import { state as routerState } from "./router"; 3 | 4 | const name = "routePlaceholder"; 5 | 6 | export default defineComponent({ 7 | name, 8 | connectToStore: { 9 | watch: [routerState.select], 10 | mapToState: ({ request, route, params }, props, oldState) => { 11 | const Component = route.placeholder === undefined ? null : route.placeholder(request, route, params); 12 | 13 | if (Component === null) { 14 | return oldState; 15 | } 16 | 17 | return { 18 | component: , 19 | }; 20 | }, 21 | }, 22 | render(props, state) { 23 | return state.component; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /universal/app/components/router/router.js: -------------------------------------------------------------------------------- 1 | import defineState from "../../store/defineState"; 2 | import contexts from "../../contexts"; 3 | import renderChild from "../util/renderChild"; 4 | import has from "../../util/has"; 5 | import routes from "../../routes"; 6 | import history from "../../effects/history"; 7 | import changeRoute from "./util/changeRoute"; 8 | import enterRoute from "./util/enterRoute"; 9 | import sanitizeRequest from "./util/sanitizeRequest"; 10 | 11 | const name = "router"; 12 | 13 | function isCurrentGetRequest(state, request) { 14 | return state.request !== null && state.request.method === "GET" && state.request.url === request.url; 15 | } 16 | 17 | export const state = defineState({ 18 | scope: name, 19 | context: contexts.state, 20 | initialState: { 21 | request: null, 22 | route: null, 23 | params: null, 24 | }, 25 | hydrate(dehydrated) { 26 | const route = dehydrated.route; 27 | 28 | return { 29 | ...dehydrated, 30 | route: route !== null && has(routes, route.name) ? routes[route.name] : null, 31 | }; 32 | }, 33 | actions: { 34 | push: changeRoute({ abortIf: isCurrentGetRequest, historyEffect: history.push }), 35 | replace: changeRoute({ abortIf: isCurrentGetRequest, historyEffect: history.replace }), 36 | enter: enterRoute, 37 | reset: url => (getState, patchState, dispatchAction, execEffect) => { 38 | const request = sanitizeRequest(url); 39 | 40 | return Promise.resolve(execEffect(history.reset, request.url)); 41 | }, 42 | }, 43 | }); 44 | 45 | export default renderChild; 46 | -------------------------------------------------------------------------------- /universal/app/components/router/util/changeRoute.js: -------------------------------------------------------------------------------- 1 | import sanitizeRequest from "./sanitizeRequest"; 2 | import resolveRouteAndParams from "./resolveRouteAndParams"; 3 | import enterRoute from "./enterRoute"; 4 | 5 | export default function changeRoute({ abortIf, historyEffect }) { 6 | return (request, statusCode) => (getState, patchState, dispatchAction, execEffect) => 7 | new Promise(resolve => { 8 | const oldState = getState(); 9 | const sanitizedReq = sanitizeRequest(request); 10 | 11 | if (abortIf(oldState, sanitizedReq)) { 12 | resolve(oldState); 13 | 14 | return; 15 | } 16 | 17 | const { route, params } = resolveRouteAndParams(sanitizedReq.parsedUrl); 18 | const enterResolvedRoute = execEffect(historyEffect, sanitizedReq.url, statusCode); 19 | 20 | resolve( 21 | enterResolvedRoute ? 22 | enterRoute(sanitizedReq, route, params)(getState, patchState, dispatchAction) : 23 | null 24 | ); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /universal/app/components/router/util/createRouter.js: -------------------------------------------------------------------------------- 1 | import nanorouter from "nanorouter"; 2 | import routes from "../../../routes"; 3 | 4 | export default function createRouter() { 5 | const router = nanorouter({ default: "/404" }); 6 | let result; 7 | 8 | Object.values(routes).forEach((route, i, arr) => { 9 | let urlPattern = route.url; 10 | 11 | if (i === arr.length - 1) { 12 | if (typeof urlPattern === "string") { 13 | // throw new Error("Expected the last catch-all route to have no url pattern"); 14 | } 15 | urlPattern = "404"; 16 | } else if (typeof urlPattern !== "string") { 17 | // Skip routes without an url pattern 18 | return; 19 | } 20 | router.on(urlPattern, urlParams => (result = { route, urlParams })); 21 | }); 22 | 23 | return url => { 24 | router(url); 25 | 26 | return result; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /universal/app/components/router/util/resolveRouteAndParams.js: -------------------------------------------------------------------------------- 1 | import createRouter from "./createRouter"; 2 | 3 | const router = createRouter(); 4 | 5 | export default function resolveRouteAndParams(parsedUrl) { 6 | const { route, urlParams } = router(parsedUrl.pathname); 7 | 8 | return { 9 | route, 10 | params: Object.assign(parsedUrl.query, urlParams), 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /universal/app/components/router/util/sanitizeRequest.js: -------------------------------------------------------------------------------- 1 | import url from "url"; 2 | 3 | const defaultRequest = { 4 | method: "GET", 5 | url: "/", 6 | body: {}, 7 | }; 8 | 9 | function parseUrlAndQueryString(u) { 10 | return url.parse(u, true); 11 | } 12 | 13 | export default function sanitizeRequest(req) { 14 | const request = typeof req === "string" ? { ...defaultRequest, url: req } : req; 15 | const parsedUrl = parseUrlAndQueryString(request.url); 16 | 17 | return { 18 | sanitized: true, 19 | method: request.method.toUpperCase(), 20 | url: parsedUrl.path + (typeof parsedUrl.hash === "string" ? parsedUrl.hash : ""), 21 | parsedUrl, 22 | body: request.body, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /universal/app/components/session/session.js: -------------------------------------------------------------------------------- 1 | import renderChild from "../util/renderChild"; 2 | import createSession from "../../effects/api/session/create"; 3 | import destroySession from "../../effects/api/session/destroy"; 4 | import defineState from "../../store/defineState"; 5 | import contexts from "../../contexts"; 6 | import session from "../../effects/session"; 7 | 8 | const name = "session"; 9 | 10 | export const state = defineState({ 11 | scope: name, 12 | context: contexts.state, 13 | initialState: { 14 | user: null, 15 | token: null, 16 | }, 17 | hydrate(dehydrated, execEffect) { 18 | const state = execEffect(session.read); 19 | 20 | return { 21 | ...dehydrated, 22 | ...state, 23 | }; 24 | }, 25 | actions: { 26 | create: (name, password) => (getState, patchState, dispatchAction, execEffect) => 27 | // No need to write the session because the API is doing that for us 28 | execEffect(createSession, name, password).then(res => { 29 | patchState({ 30 | user: res.user, 31 | token: res.token, 32 | }); 33 | }), 34 | destroy: () => (getState, patchState, dispatchAction, execEffect) => 35 | // No need to write the session because the API is doing that for us 36 | execEffect(destroySession, getState().token).then(res => { 37 | patchState({ 38 | user: null, 39 | token: null, 40 | }); 41 | }), 42 | }, 43 | }); 44 | 45 | export default renderChild; 46 | -------------------------------------------------------------------------------- /universal/app/components/store/store.js: -------------------------------------------------------------------------------- 1 | import defineComponent from "../util/defineComponent"; 2 | import defineState from "../../store/defineState"; 3 | import contexts from "../../contexts"; 4 | 5 | const name = "store"; 6 | 7 | export const state = defineState({ 8 | scope: name, 9 | context: contexts.state, 10 | actions: { 11 | hydrateStates() { 12 | return (getState, patchState, dispatchAction, execEffect) => { 13 | Object.values(contexts.state.scopes).map(scope => dispatchAction(scope.hydrate())); 14 | }; 15 | }, 16 | }, 17 | }); 18 | 19 | export default defineComponent({ 20 | name, 21 | getChildContext(props) { 22 | return { 23 | ...this, 24 | store: props.store, 25 | }; 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /universal/app/components/top5/top5.js: -------------------------------------------------------------------------------- 1 | import defineState from "../../store/defineState"; 2 | import contexts from "../../contexts"; 3 | import defineComponent from "../util/defineComponent"; 4 | import getTop5 from "../../effects/api/posts/getTop5"; 5 | import Posts from "../posts/posts"; 6 | 7 | const name = "top5"; 8 | 9 | export const state = defineState({ 10 | scope: name, 11 | context: contexts.state, 12 | initialState: { 13 | posts: null, 14 | }, 15 | actions: { 16 | enter: () => (getState, patchState, dispatchAction, execEffect) => 17 | execEffect(getTop5).then(posts => { 18 | patchState({ 19 | posts, 20 | }); 21 | }), 22 | update: () => (getState, patchState, dispatchAction, execEffect) => Function.prototype, 23 | }, 24 | }); 25 | 26 | export default defineComponent({ 27 | name, 28 | connectToStore: { 29 | watch: [state.select], 30 | }, 31 | render(props, state) { 32 | return ; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /universal/app/components/util/attrSelector.js: -------------------------------------------------------------------------------- 1 | export default function attrSelector(cssSelector) { 2 | return `[data-${ cssSelector }]`; 3 | } 4 | -------------------------------------------------------------------------------- /universal/app/components/util/hookIntoEvent.js: -------------------------------------------------------------------------------- 1 | export default function hookIntoEvent(eventProp, handler) { 2 | return (...args) => { 3 | const e = args[1]; 4 | const props = args[2]; 5 | const originalHandler = props[eventProp]; 6 | 7 | handler(...args); 8 | 9 | if (typeof originalHandler === "function") { 10 | originalHandler.call(e.currentTarget, e); 11 | } 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /universal/app/components/util/renderChild.js: -------------------------------------------------------------------------------- 1 | export default function RenderChild({ children }) { 2 | return children[0]; 3 | } 4 | -------------------------------------------------------------------------------- /universal/app/components/util/withContext.js: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import renderChild from "./renderChild"; 3 | 4 | export default class WithContext extends Component { 5 | constructor() { 6 | super(); 7 | this.render = renderChild; 8 | } 9 | getChildContext() { 10 | return this.props.context; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /universal/app/contexts.js: -------------------------------------------------------------------------------- 1 | export default { 2 | state: { 3 | name: "app", 4 | scopes: Object.create(null), 5 | }, 6 | chunkEntries: Object.create(null), 7 | }; 8 | -------------------------------------------------------------------------------- /universal/app/createApp.js: -------------------------------------------------------------------------------- 1 | import App from "./components/app/app"; 2 | import createStore from "./store/createStore"; 3 | import contexts from "./contexts"; 4 | 5 | export default function createApp(initialState, effectContext) { 6 | const store = createStore(contexts.state, initialState, effectContext); 7 | const app = ; 8 | 9 | return { 10 | app, 11 | store, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /universal/app/effects/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /universal/app/effects/api/api.browser.js: -------------------------------------------------------------------------------- 1 | import fetch from "unfetch"; 2 | 3 | const root = "/api"; 4 | 5 | export default function api() { 6 | return (url, options) => fetch(root + url, options); 7 | } 8 | -------------------------------------------------------------------------------- /universal/app/effects/api/api.node.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { parse as parseCookie } from "cookie"; 3 | import config from "../../../config/server"; 4 | 5 | const root = `http://${ config.hostname }:${ config.port }/api`; 6 | 7 | function includeCredentials(options) { 8 | return typeof options.credentials === "string" && options.credentials.toLowerCase() === "same-origin"; 9 | } 10 | 11 | export default function api({ req, res }) { 12 | return (url, options = {}) => { 13 | if (includeCredentials(options)) { 14 | options.headers = options.headers || {}; 15 | options.headers.cookie = req.headers.cookie; 16 | } 17 | 18 | return fetch(root + url, options).then(apiRes => { 19 | const cookie = apiRes.headers.get("set-cookie"); 20 | 21 | if (!cookie) { 22 | return apiRes; 23 | } 24 | 25 | const oldCookie = typeof req.headers.cookie === "string" ? parseCookie(req.headers.cookie) : null; 26 | const newCookie = parseCookie(cookie); 27 | 28 | if (oldCookie === null || oldCookie[config.session.name] === newCookie[config.session.name]) { 29 | return apiRes; 30 | } 31 | 32 | // The API responded with a different session, we need to switch this one 33 | return new Promise((resolve, reject) => { 34 | req.session.destroy(err => { 35 | if (err) { 36 | reject(err); 37 | 38 | return; 39 | } 40 | res.removeHeader("Set-Cookie"); 41 | res.header("Set-Cookie", cookie); 42 | resolve(apiRes); 43 | }); 44 | }); 45 | }); 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /universal/app/effects/api/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/effects/api/index.js -------------------------------------------------------------------------------- /universal/app/effects/api/posts/getAll.js: -------------------------------------------------------------------------------- 1 | import api from "../../api"; 2 | 3 | export default function getAll(context) { 4 | return () => api(context)("/posts").then(res => res.json()).then(res => res.items); 5 | } 6 | -------------------------------------------------------------------------------- /universal/app/effects/api/posts/getTop5.js: -------------------------------------------------------------------------------- 1 | import api from "../../api"; 2 | 3 | export default function getTop5(context) { 4 | return () => api(context)("/posts?limit=5&sortBy=starred").then(res => res.json()).then(res => res.items); 5 | } 6 | -------------------------------------------------------------------------------- /universal/app/effects/api/session/create.js: -------------------------------------------------------------------------------- 1 | import api from "../../api"; 2 | 3 | const defaultOptions = { 4 | method: "POST", 5 | credentials: "same-origin", 6 | headers: { 7 | "Content-Type": "application/json", 8 | }, 9 | }; 10 | 11 | export default function create(context) { 12 | return (name, password) => 13 | api(context)("/session", { 14 | ...defaultOptions, 15 | body: JSON.stringify({ name, password }), 16 | }) 17 | .then(res => res.json()) 18 | .then(res => { 19 | if (res.status === "success") { 20 | return res.data; 21 | } 22 | 23 | throw new Error(res.message); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /universal/app/effects/api/session/destroy.js: -------------------------------------------------------------------------------- 1 | import api from "../../api"; 2 | 3 | function getOptions(token) { 4 | return { 5 | method: "DELETE", 6 | credentials: "same-origin", 7 | headers: { 8 | "Content-Type": "application/json", 9 | Authorization: "JWT " + token, 10 | }, 11 | }; 12 | } 13 | 14 | export default function destroy(context) { 15 | return token => 16 | api(context)("/session", getOptions(token)).then(res => res.json()).then(res => { 17 | if (res.status === "success") { 18 | return res.data; 19 | } 20 | 21 | throw new Error(res.message); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /universal/app/effects/csrf/csrf.browser.js: -------------------------------------------------------------------------------- 1 | export default function csrf({ req }) { 2 | return () => null; 3 | } 4 | -------------------------------------------------------------------------------- /universal/app/effects/csrf/csrf.node.js: -------------------------------------------------------------------------------- 1 | import has from "../../util/has"; 2 | 3 | export default function csrf({ req }) { 4 | return () => { 5 | const token = has(req.session, "csrf") ? req.session.csrf : req.csrfToken(); 6 | 7 | req.session.csrf = token; 8 | 9 | return token; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /universal/app/effects/csrf/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/effects/csrf/index.js -------------------------------------------------------------------------------- /universal/app/effects/document/document.browser.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setTitle: () => title => { 3 | document.title = title; 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /universal/app/effects/document/document.node.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setTitle: () => Function.prototype, 3 | }; 4 | -------------------------------------------------------------------------------- /universal/app/effects/document/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/effects/document/index.js -------------------------------------------------------------------------------- /universal/app/effects/history/history.browser.js: -------------------------------------------------------------------------------- 1 | export default { 2 | push: () => url => { 3 | history.pushState(null, "", url); 4 | 5 | return true; // true = enter the next route 6 | }, 7 | replace: () => url => { 8 | history.replaceState(null, "", url); 9 | 10 | return true; // true = enter the next route 11 | }, 12 | reset: () => url => { 13 | try { 14 | localStorage.clear(); 15 | } catch (err) { 16 | console.error(err); 17 | } 18 | try { 19 | sessionStorage.clear(); 20 | } catch (err) { 21 | console.error(err); 22 | } 23 | window.location = url; 24 | 25 | return true; // true = enter the next route 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /universal/app/effects/history/history.node.js: -------------------------------------------------------------------------------- 1 | import { SEE_OTHER } from "../../util/statusCodes"; 2 | 3 | export default { 4 | push: () => url => { 5 | throw new Error(`Cannot push ${ url } to server history. Use replace() to respond with a redirect.`); 6 | }, 7 | replace: ({ res }) => (url, statusCode = SEE_OTHER) => { 8 | // It is important to use the redirect() method here or otherwise express-session 9 | // won't save the session on POST requests 10 | // https://stackoverflow.com/a/26532987 11 | res.redirect(statusCode, url); 12 | 13 | return false; // false = do not enter the next route 14 | }, 15 | reset: ({ res }) => (url, statusCode = SEE_OTHER) => { 16 | res.redirect(statusCode, url); 17 | 18 | return false; // false = do not enter the next route 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /universal/app/effects/history/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/effects/history/index.js -------------------------------------------------------------------------------- /universal/app/effects/session/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhnns/spa-vs-universal/2224ebc5e24f0988c6b611a0752385e695e5fa7e/universal/app/effects/session/index.js -------------------------------------------------------------------------------- /universal/app/effects/session/session.browser.js: -------------------------------------------------------------------------------- 1 | function returnNull() { 2 | return null; 3 | } 4 | 5 | export default { 6 | read: () => returnNull, 7 | write: () => Function.prototype, 8 | readFlash: () => returnNull, 9 | writeFlash: () => Function.prototype, 10 | }; 11 | -------------------------------------------------------------------------------- /universal/app/effects/session/session.node.js: -------------------------------------------------------------------------------- 1 | import has from "../../util/has"; 2 | 3 | export default { 4 | read: ({ req }) => () => req.session, 5 | write: ({ req }) => session => { 6 | Object.assign(req.session, session); 7 | }, 8 | readFlash: ({ req }) => key => { 9 | const flashes = req.session.flashes ? req.session.flashes : {}; 10 | 11 | if (has(flashes, key) === false) { 12 | return null; 13 | } 14 | 15 | const value = flashes[key]; 16 | 17 | delete flashes[key]; 18 | 19 | return value; 20 | }, 21 | writeFlash: ({ req }) => (key, value) => { 22 | const flashes = req.session.flashes ? req.session.flashes : {}; 23 | 24 | flashes[key] = value; 25 | req.session.flashes = flashes; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /universal/app/env.js: -------------------------------------------------------------------------------- 1 | const env = process.env.NODE_ENV || "development"; 2 | 3 | export const isProd = env === "production"; 4 | export const isDev = isProd === false; 5 | 6 | export default env; 7 | -------------------------------------------------------------------------------- /universal/app/routes/about/about.js: -------------------------------------------------------------------------------- 1 | import { state as documentState } from "../../components/document/document"; 2 | import Component, { state } from "../../components/about/about"; 3 | 4 | export function GET(request, route, params) { 5 | return (dispatchAction, getState, execEffect) => { 6 | dispatchAction( 7 | documentState.actions.update({ 8 | statusCode: 200, 9 | title: "About", 10 | headerTags: [], 11 | }) 12 | ); 13 | 14 | return state.actions; 15 | }; 16 | } 17 | 18 | export default Component; 19 | -------------------------------------------------------------------------------- /universal/app/routes/about/index.js: -------------------------------------------------------------------------------- 1 | import defineChunkEntry from "../../components/chunks/defineChunkEntry"; 2 | import contexts from "../../contexts"; 3 | 4 | export default defineChunkEntry({ 5 | chunk: "about", 6 | context: contexts.chunkEntries, 7 | load: () => import("./about" /* webpackChunkName: "about" */), 8 | }); 9 | -------------------------------------------------------------------------------- /universal/app/routes/allPosts/allPosts.js: -------------------------------------------------------------------------------- 1 | import { state as documentState } from "../../components/document/document"; 2 | import Component, { state } from "../../components/allPosts/allPosts"; 3 | 4 | export function GET(request, route, params) { 5 | return (dispatchAction, getState, execEffect) => { 6 | dispatchAction( 7 | documentState.actions.update({ 8 | statusCode: 200, 9 | title: "All Peerigon News", 10 | headerTags: [], 11 | }) 12 | ); 13 | 14 | return state.actions; 15 | }; 16 | } 17 | 18 | export default Component; 19 | -------------------------------------------------------------------------------- /universal/app/routes/allPosts/index.js: -------------------------------------------------------------------------------- 1 | import defineChunkEntry from "../../components/chunks/defineChunkEntry"; 2 | import contexts from "../../contexts"; 3 | 4 | export default defineChunkEntry({ 5 | name: "allPosts", 6 | chunk: "posts", 7 | context: contexts.chunkEntries, 8 | load: () => import("./allPosts" /* webpackChunkName: "posts" */), 9 | }); 10 | -------------------------------------------------------------------------------- /universal/app/routes/error/error.js: -------------------------------------------------------------------------------- 1 | import { state as documentState } from "../../components/document/document"; 2 | import Component, { state } from "../../components/error/error"; 3 | import has from "../../util/has"; 4 | 5 | const emptyArr = []; 6 | 7 | export function GET(request, route, params) { 8 | return (dispatchAction, getState, execEffect) => { 9 | const statusCode = has(params, "statusCode") ? params.statusCode : 500; 10 | const title = has(params, "title") ? params.title : "Error"; 11 | const headerTags = has(params, "headerTags") ? params.headerTags : emptyArr; 12 | 13 | dispatchAction( 14 | documentState.actions.update({ 15 | statusCode, 16 | title, 17 | headerTags, 18 | }) 19 | ); 20 | 21 | return state.actions; 22 | }; 23 | } 24 | 25 | export default Component; 26 | -------------------------------------------------------------------------------- /universal/app/routes/error/index.js: -------------------------------------------------------------------------------- 1 | import defineChunkEntry from "../../components/chunks/defineChunkEntry"; 2 | import contexts from "../../contexts"; 3 | 4 | export default defineChunkEntry({ 5 | chunk: "error", 6 | context: contexts.chunkEntries, 7 | load: () => import("./error" /* webpackChunkName: "error" */), 8 | }); 9 | -------------------------------------------------------------------------------- /universal/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import top5 from "./top5"; 2 | import allPosts from "./allPosts"; 3 | import about from "./about"; 4 | import session from "./session"; 5 | import error from "./error"; 6 | import notFound from "./notFound"; 7 | import addObjectKeys from "../util/addObjectKeys"; 8 | 9 | function defineRoutes(routes) { 10 | return addObjectKeys(routes, "name"); 11 | } 12 | 13 | export default defineRoutes({ 14 | top5: { 15 | url: "/", 16 | entry: top5.import, 17 | placeholder: () => top5.Placeholder, 18 | }, 19 | allPosts: { 20 | url: "/all", 21 | entry: allPosts.import, 22 | placeholder: () => allPosts.Placeholder, 23 | }, 24 | about: { 25 | url: "/about", 26 | entry: about.import, 27 | placeholder: () => about.Placeholder, 28 | }, 29 | session: { 30 | url: "/session", 31 | entry: session.import, 32 | }, 33 | error: { 34 | entry: error.import, 35 | error: true, 36 | placeholder: () => error.Placeholder, 37 | }, 38 | notFound: { 39 | entry: notFound.import, 40 | error: true, 41 | placeholder: () => notFound.Placeholder, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /universal/app/routes/notFound/index.js: -------------------------------------------------------------------------------- 1 | import defineChunkEntry from "../../components/chunks/defineChunkEntry"; 2 | import contexts from "../../contexts"; 3 | 4 | export default defineChunkEntry({ 5 | chunk: "notFound", 6 | context: contexts.chunkEntries, 7 | load: () => import("./notFound" /* webpackChunkName: "notFound" */), 8 | }); 9 | -------------------------------------------------------------------------------- /universal/app/routes/notFound/notFound.js: -------------------------------------------------------------------------------- 1 | import { state as routerState } from "../../components/router/router"; 2 | import Component from "../../components/error/error"; 3 | import routes from "../../routes"; 4 | 5 | export function GET(request, route, params) { 6 | return (dispatchAction, getState, execEffect) => { 7 | dispatchAction( 8 | routerState.actions.enter(request, routes.error, { 9 | statusCode: 404, 10 | title: "Not Found", 11 | message: "The requested route does not exist", 12 | }) 13 | ); 14 | }; 15 | } 16 | 17 | export default Component; 18 | -------------------------------------------------------------------------------- /universal/app/routes/session/index.js: -------------------------------------------------------------------------------- 1 | import defineChunkEntry from "../../components/chunks/defineChunkEntry"; 2 | import contexts from "../../contexts"; 3 | 4 | export default defineChunkEntry({ 5 | chunk: "session", 6 | context: contexts.chunkEntries, 7 | load: () => import("./session" /* webpackChunkName: "session" */), 8 | }); 9 | -------------------------------------------------------------------------------- /universal/app/routes/session/session.js: -------------------------------------------------------------------------------- 1 | import { state as routerState } from "../../components/router/router"; 2 | import { state as sessionState } from "../../components/session/session"; 3 | import { SEE_OTHER } from "../../util/statusCodes"; 4 | import contexts from "../../contexts"; 5 | import routes from "../../routes"; 6 | 7 | export function POST(request, route, params) { 8 | return (dispatchAction, getState, execEffect) => { 9 | function abort() { 10 | dispatchAction(formState.actions.saveInSessionFlash()); 11 | 12 | return dispatchAction(routerState.actions.replace(params.previous, SEE_OTHER)); 13 | } 14 | 15 | const form = params.form; 16 | const formState = contexts.state.scopes[form]; 17 | const formData = request.body; 18 | 19 | dispatchAction(formState.actions.fillOut(formData)); 20 | 21 | const validationResult = dispatchAction(formState.actions.validate()); 22 | 23 | if (validationResult.isValid === false) { 24 | return abort(); 25 | } 26 | 27 | dispatchAction(formState.actions.updateSubmitResult(null)); 28 | 29 | return dispatchAction(sessionState.actions.create(formData.name, formData.password)).then( 30 | result => { 31 | dispatchAction(formState.actions.updateSubmitResult(result)); 32 | dispatchAction(formState.actions.clear()); 33 | 34 | return dispatchAction(routerState.actions.replace(params.next, SEE_OTHER)); 35 | }, 36 | result => { 37 | dispatchAction(formState.actions.updateSubmitResult(result)); 38 | 39 | return abort(); 40 | } 41 | ); 42 | }; 43 | } 44 | 45 | export function DELETE(request, route, params) { 46 | return (dispatchAction, getState, execEffect) => 47 | dispatchAction(sessionState.actions.destroy()).then( 48 | () => dispatchAction(routerState.actions.reset(params.next)), 49 | err => dispatchAction(routerState.actions.enter(request, routes.error, err)) 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /universal/app/routes/top5/index.js: -------------------------------------------------------------------------------- 1 | import defineChunkEntry from "../../components/chunks/defineChunkEntry"; 2 | import contexts from "../../contexts"; 3 | 4 | export default defineChunkEntry({ 5 | name: "top5", 6 | chunk: "posts", 7 | context: contexts.chunkEntries, 8 | load: () => import("./top5" /* webpackChunkName: "posts" */), 9 | }); 10 | -------------------------------------------------------------------------------- /universal/app/routes/top5/top5.js: -------------------------------------------------------------------------------- 1 | import { state as documentState } from "../../components/document/document"; 2 | import Component, { state } from "../../components/top5/top5"; 3 | 4 | export function GET(request, route, params) { 5 | return (dispatchAction, getState, execEffect) => { 6 | dispatchAction( 7 | documentState.actions.update({ 8 | statusCode: 200, 9 | title: "Top 5 Peerigon News", 10 | headerTags: [], 11 | }) 12 | ); 13 | 14 | return state.actions; 15 | }; 16 | } 17 | 18 | export default Component; 19 | -------------------------------------------------------------------------------- /universal/app/server/assetTags.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { isProd } from "../env"; 3 | import { assetsJson as pathToAssetsJson } from "./paths"; 4 | 5 | const assetTags = isProd === true ? prepareAssetTags() : null; 6 | 7 | function prepareAssetTags() { 8 | const assetsJson = JSON.parse(fs.readFileSync(pathToAssetsJson, "utf8")); 9 | 10 | return Object.keys(assetsJson).reduce((assetTags, chunkName) => { 11 | const assets = assetsJson[chunkName]; 12 | 13 | assetTags[chunkName] = assets 14 | .map(asset => asset.replace(/\.gz$/, "")) 15 | .map(asset => { 16 | if (/\.js$/.test(asset) === true) { 17 | return ``; 18 | } 19 | if (/\.css$/.test(asset) === true) { 20 | return ``; 21 | } 22 | 23 | return ""; 24 | }) 25 | .join(""); 26 | 27 | return assetTags; 28 | }, {}); 29 | } 30 | 31 | export default function get(chunkName) { 32 | const tags = assetTags === null ? prepareAssetTags() : assetTags; 33 | const tag = tags[chunkName]; 34 | 35 | if (typeof tag !== "string") { 36 | throw new Error(`No asset tag for chunk ${ chunkName }`); 37 | } 38 | 39 | return tag; 40 | } 41 | -------------------------------------------------------------------------------- /universal/app/server/createRenderStream.js: -------------------------------------------------------------------------------- 1 | import renderToString from "preact-render-to-string"; 2 | import streamTemplate from "stream-template"; 3 | import serializeJavascript from "serialize-javascript"; 4 | import assetTags from "./assetTags"; 5 | 6 | export default function createRenderStream({ title, headerTags, html, state, chunks }) { 7 | const renderedHeaderTags = Promise.resolve(headerTags).then(nodes => 8 | nodes.map(renderToString).reduce((str, tag) => str + tag, "") 9 | ); 10 | const renderedState = state.then(state => serializeJavascript(state, { isJSON: true, space: 0 })); 11 | 12 | return streamTemplate` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ${ title } 22 | ${ renderedHeaderTags } 23 | ${ assetTags("client") } 24 | ${ chunks.then(chunkNames => chunkNames.map(assetTags).join("")) } 25 | 26 | 27 | ${ html } 28 | 31 | 32 | 33 | `; 34 | } 35 | -------------------------------------------------------------------------------- /universal/app/server/index.js: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | 3 | global.h = h; 4 | 5 | export default function handleRequest(req, res) { 6 | const createApp = require("../createApp").default; 7 | const renderApp = require("./renderApp").default; 8 | const createRenderStream = require("./createRenderStream").default; 9 | const routerState = require("../components/router/router").state; 10 | const documentState = require("../components/document/document").state; 11 | const storeState = require("../components/store/store").state; 12 | const preloadAllChunkEntries = require("./preloadAllChunkEntries").default; 13 | const has = require("../util/has").default; 14 | const routes = require("../routes").default; 15 | 16 | const initialState = {}; 17 | const effectContext = { req, res }; 18 | const { app, store } = createApp(initialState, effectContext); 19 | const firstRouterAction = has(req, "error") ? 20 | routerState.actions.enter(req, routes.error, req.error) : 21 | routerState.actions.enter(req); 22 | 23 | res.header("Content-Type", "text/html"); 24 | 25 | store.dispatch(storeState.actions.hydrateStates()); 26 | 27 | const routingFinished = preloadAllChunkEntries().then(() => store.dispatch(firstRouterAction)); 28 | 29 | store.when(s => documentState.select(s).statusCode).then(statusCode => { 30 | const appRendered = routingFinished.then(() => renderApp(app, store)); 31 | 32 | res.statusCode = statusCode; 33 | createRenderStream({ 34 | title: store.when(s => documentState.select(s).title), 35 | headerTags: store.when(s => documentState.select(s).headerTags), 36 | html: appRendered.then(({ html }) => html), 37 | state: appRendered.then(({ state }) => state), 38 | chunks: appRendered.then(({ chunks }) => chunks), 39 | }).pipe(res); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /universal/app/server/paths.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export const assetsJson = path.resolve(process.cwd(), "dist", "public", "assets.json"); 4 | -------------------------------------------------------------------------------- /universal/app/server/preloadAllChunkEntries.js: -------------------------------------------------------------------------------- 1 | import contexts from "../contexts"; 2 | 3 | let promise = null; 4 | 5 | export default function preloadAllChunkEntries() { 6 | if (promise !== null) { 7 | return promise; 8 | } 9 | 10 | return (promise = Promise.all(Object.values(contexts.chunkEntries).map(entry => entry.load()))); 11 | } 12 | -------------------------------------------------------------------------------- /universal/app/server/renderApp.js: -------------------------------------------------------------------------------- 1 | import renderToString from "preact-render-to-string"; 2 | import { selectLoadedChunks } from "../components/chunks/chunks"; 3 | 4 | export default function renderApp(app, store) { 5 | return Promise.resolve().then(() => renderToString(app)).then(html => { 6 | const state = store.getState(); 7 | 8 | return { 9 | html, 10 | state, 11 | chunks: selectLoadedChunks(state), 12 | }; 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /universal/app/store/createReducer.js: -------------------------------------------------------------------------------- 1 | // 1 = context, 2 = scope, 3 = action, 4 = mutation 2 | const actionTypePattern = /^(\w+)\/(\w+)\/(\w+)\/(\w+)$/i; 3 | 4 | export default function createReducer(stateContext) { 5 | return function (state = {}, action) { 6 | const typeMatch = actionTypePattern.exec(action.type); 7 | 8 | if (typeMatch !== null) { 9 | const scope = typeMatch[2]; 10 | const updateType = typeMatch[4]; 11 | 12 | switch (updateType) { 13 | case "patch": 14 | return { 15 | ...state, 16 | [scope]: { 17 | ...stateContext.scopes[scope].select(state), 18 | ...action.payload, 19 | }, 20 | }; 21 | case "put": 22 | return { 23 | ...state, 24 | [scope]: action.payload, 25 | }; 26 | } 27 | } 28 | 29 | return state; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /universal/app/store/createStore.js: -------------------------------------------------------------------------------- 1 | import { createStore as reduxCreateStore, compose, applyMiddleware } from "redux"; 2 | import effectMiddleware from "./effectMiddleware"; 3 | import thunkMiddleware from "./thunkMiddleware"; 4 | import createReducer from "./createReducer"; 5 | import enhanceStore from "./enhanceStore"; 6 | 7 | const useReduxDevTools = process.env.NODE_ENV === "development" && typeof devToolsExtension === "function"; // eslint-disable-line no-undef 8 | 9 | export default function createStore(stateContext, initialState, effectContext) { 10 | return reduxCreateStore( 11 | createReducer(stateContext), 12 | initialState, 13 | compose( 14 | applyMiddleware(thunkMiddleware(), effectMiddleware((effect, args) => effect(effectContext)(...args))), 15 | enhanceStore(stateContext), 16 | // Use redux devtools when installed in the browser 17 | // @see https://github.com/zalmoxisus/redux-devtools-extension#implementation 18 | useReduxDevTools ? devToolsExtension() : f => f // eslint-disable-line no-undef 19 | ) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /universal/app/store/defineState.js: -------------------------------------------------------------------------------- 1 | import has from "../util/has"; 2 | 3 | const emptyObj = {}; 4 | 5 | function returnThis() { 6 | return this; // eslint-disable-line no-invalid-this 7 | } 8 | 9 | function isDehydratable(state) { 10 | return typeof state.toJSON === "function"; 11 | } 12 | 13 | export default function defineState(descriptor) { 14 | const scope = descriptor.scope; 15 | const context = descriptor.context; 16 | const namespace = context.name + "/" + scope; 17 | const initialState = has(descriptor, "initialState") ? descriptor.initialState : emptyObj; 18 | const hydrate = descriptor.hydrate; 19 | 20 | function selectState(contextState) { 21 | return has(contextState, scope) ? contextState[scope] : initialState; 22 | } 23 | 24 | function isHydrated(state) { 25 | return hydrate === undefined || (state !== initialState && typeof state.toJSON === "function"); 26 | } 27 | 28 | if (typeof scope !== "string") { 29 | throw new Error("Scope is missing"); 30 | } 31 | if (context === undefined) { 32 | throw new Error("State context is missing"); 33 | } 34 | if (has(context.scopes, scope)) { 35 | throw new Error(`Scope ${ scope } is already defined on given state context`); 36 | } 37 | 38 | const actionDescriptor = has(descriptor, "actions") ? descriptor.actions : emptyObj; 39 | const state = { 40 | context, 41 | namespace, 42 | scope, 43 | actions: Object.keys(actionDescriptor).reduce((actions, actionName) => { 44 | const execute = actionDescriptor[actionName]; 45 | const type = namespace + "/" + actionName; 46 | 47 | actions[actionName] = (...args) => (dispatchAction, getState, execEffect) => { 48 | function getScopedState() { 49 | return selectState(getState()); 50 | } 51 | 52 | function patchState(patch) { 53 | return dispatchAction({ 54 | type: type + "/patch", 55 | payload: patch, 56 | }); 57 | } 58 | 59 | return execute(...args)(getScopedState, patchState, dispatchAction, execEffect); 60 | }; 61 | 62 | return actions; 63 | }, {}), 64 | hydrate() { 65 | return (dispatchAction, getState, execEffect) => { 66 | const dehydrated = selectState(getState()); 67 | 68 | if (isHydrated(dehydrated)) { 69 | return; 70 | } 71 | 72 | const hydrated = hydrate(dehydrated, execEffect); 73 | 74 | if (isDehydratable(hydrated) === false) { 75 | hydrated.toJSON = returnThis; 76 | } 77 | 78 | dispatchAction({ 79 | type: namespace + "/hydrate/put", 80 | payload: hydrated, 81 | }); 82 | }; 83 | }, 84 | select: selectState, 85 | }; 86 | 87 | context.scopes[scope] = state; 88 | 89 | return state; 90 | } 91 | -------------------------------------------------------------------------------- /universal/app/store/effectMiddleware.js: -------------------------------------------------------------------------------- 1 | import has from "../util/has"; 2 | 3 | export function createEffectAction(effect, args) { 4 | return { 5 | type: "effect", 6 | payload: { 7 | effect, 8 | args, 9 | }, 10 | }; 11 | } 12 | 13 | export default function effectMiddleware(execEffect) { 14 | return store => next => action => { 15 | if (has(action, "payload")) { 16 | const payload = action.payload; 17 | 18 | if (has(payload, "effect")) { 19 | return execEffect(payload.effect, payload.args); 20 | } 21 | } 22 | 23 | return next(action); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /universal/app/store/enhanceStore.js: -------------------------------------------------------------------------------- 1 | function isDefined(result) { 2 | return result !== null && result !== undefined; 3 | } 4 | 5 | export default function enhanceStore(stateContext) { 6 | return createStore => (reducers, initialState, enhancers) => { 7 | const store = createStore(reducers, initialState, enhancers); 8 | const enhancedStore = { 9 | ...store, 10 | context: stateContext, 11 | watch(select, onChange) { 12 | let oldValue = select(this.getState()); 13 | 14 | return this.subscribe(() => { 15 | const newValue = select(this.getState()); 16 | 17 | if (oldValue !== newValue) { 18 | onChange(newValue, oldValue); 19 | oldValue = newValue; 20 | } 21 | }); 22 | }, 23 | when(select, condition = isDefined) { 24 | return new Promise((resolve, reject) => { 25 | let unsubscribe = Function.prototype; 26 | 27 | function check(value) { 28 | const result = condition(value); 29 | 30 | if (result === true) { 31 | unsubscribe(); 32 | resolve(value); 33 | } 34 | 35 | return result; 36 | } 37 | 38 | if (check(select(this.getState())) === false) { 39 | unsubscribe = this.watch(select, check); 40 | } 41 | }); 42 | }, 43 | }; 44 | 45 | return enhancedStore; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /universal/app/store/thunkMiddleware.js: -------------------------------------------------------------------------------- 1 | import { createEffectAction } from "./effectMiddleware"; 2 | 3 | function executeAction(action, dispatchAction, getState) { 4 | function execEffect(effect, ...args) { 5 | return dispatchAction(createEffectAction(effect, args)); 6 | } 7 | 8 | return action(dispatchAction, getState, execEffect); 9 | } 10 | 11 | export default function thunkMiddleware() { 12 | return ({ dispatch, getState }) => next => action => { 13 | if (typeof action === "function") { 14 | return executeAction(action, dispatch, getState); 15 | } 16 | 17 | return next(action); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /universal/app/styles/a11y.js: -------------------------------------------------------------------------------- 1 | export const offscreen = { 2 | position: "absolute", 3 | left: -10000, 4 | top: "auto", 5 | width: 1, 6 | height: 1, 7 | overflow: "hidden", 8 | }; 9 | -------------------------------------------------------------------------------- /universal/app/styles/block/inputSubmit.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import { rem } from "../../styles/scales"; 3 | import { mint } from "../../styles/colors"; 4 | import { regularFontSize, regularLineHeight } from "../../styles/typoSizes"; 5 | import nexaXBold from "../../styles/type/nexaXBold"; 6 | import { regular } from "../borders"; 7 | import { repeatingLinear } from "../gradients"; 8 | 9 | const stripeColor = "rgba(0, 0, 0, 0.1)"; 10 | const stripeAnimation = css.keyframes({ 11 | from: { 12 | backgroundPosition: "0 0", 13 | }, 14 | to: { 15 | backgroundPosition: "71px 0px", 16 | }, 17 | }); 18 | // Mobile safari adds weird styles 19 | const mobileSafariStyleFixes = { 20 | WebkitAppearance: "none", 21 | borderRadius: 0, 22 | }; 23 | 24 | export default { 25 | ...nexaXBold, 26 | ...mobileSafariStyleFixes, 27 | width: "100%", 28 | fontSize: regularFontSize + "rem", 29 | lineHeight: regularLineHeight + "rem", 30 | padding: rem(7) + "rem 0", 31 | border: "none", 32 | outline: "none", 33 | backgroundColor: mint(), 34 | boxShadow: "0 0px 0px rgba(0, 0, 0, 0.3)", 35 | transition: "box-shadow 0.1s ease-in-out", 36 | ":hover": { 37 | cursor: "pointer", 38 | boxShadow: "3px 3px 3px rgba(0, 0, 0, 0.2), -3px 3px 3px rgba(0, 0, 0, 0.2)", 39 | }, 40 | ":focus": { 41 | outline: regular(), 42 | }, 43 | "[data-pending]": { 44 | backgroundImage: repeatingLinear("-45deg", [ 45 | "transparent 0", 46 | "transparent 25px", 47 | stripeColor + " 25px", 48 | stripeColor + " 50px", 49 | ]), 50 | backgroundSize: "71px 50px", 51 | animation: stripeAnimation + " 2s linear infinite", 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /universal/app/styles/block/inputText.js: -------------------------------------------------------------------------------- 1 | import { rem } from "../../styles/scales"; 2 | import { mint, red } from "../../styles/colors"; 3 | import { regularFontSize, regularLineHeight } from "../../styles/typoSizes"; 4 | import latoLight from "../../styles/type/latoLight"; 5 | import { regular } from "../borders"; 6 | 7 | const mobileSafariStyleFixes = { 8 | WebkitAppearance: "none", 9 | borderRadius: 0, 10 | }; 11 | 12 | export default { 13 | ...latoLight, 14 | ...mobileSafariStyleFixes, 15 | width: "100%", 16 | fontSize: regularFontSize + "rem", 17 | lineHeight: regularLineHeight + "rem", 18 | padding: rem(7) + "rem 0", 19 | border: "none", 20 | borderBottom: regular(), 21 | outline: "none", 22 | ":focus": { 23 | borderColor: mint(), 24 | outlineColor: mint(), 25 | }, 26 | "[invalid]": { 27 | borderBottomColor: red(), 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /universal/app/styles/block/sheet.js: -------------------------------------------------------------------------------- 1 | import { px } from "../../styles/scales"; 2 | import { white, black } from "../../styles/colors"; 3 | 4 | export const sheetPadding = px(13); 5 | 6 | export default { 7 | color: black(), 8 | backgroundColor: white(), 9 | padding: sheetPadding, 10 | }; 11 | -------------------------------------------------------------------------------- /universal/app/styles/borders.js: -------------------------------------------------------------------------------- 1 | import { black } from "./colors"; 2 | 3 | export const defaultColor = black(); 4 | export const regularWidth = 2; 5 | export const strongWidth = 4; 6 | 7 | export function regular(color = defaultColor) { 8 | return `${ regularWidth }px solid ${ color }`; 9 | } 10 | 11 | export function strong(color = defaultColor) { 12 | return `${ strongWidth }px solid ${ color }`; 13 | } 14 | -------------------------------------------------------------------------------- /universal/app/styles/calc.js: -------------------------------------------------------------------------------- 1 | export default function (...bits) { 2 | return `calc(${ bits.join("") })`; 3 | } 4 | -------------------------------------------------------------------------------- /universal/app/styles/colors.js: -------------------------------------------------------------------------------- 1 | import { color, lightness } from "kewler"; 2 | 3 | export const mint = color("#46e1c8"); 4 | export const mintLight35 = mint(lightness(35)); 5 | export const white = color("#fff"); 6 | export const silver = color("#e6e1de"); 7 | export const silverLight10 = silver(lightness(10)); 8 | export const black = color("#282828"); 9 | export const blackLight30 = black(lightness(40)); 10 | export const red = color("#f14936"); 11 | -------------------------------------------------------------------------------- /universal/app/styles/gradients.js: -------------------------------------------------------------------------------- 1 | export function linear(dir, colorStops) { 2 | return `linear-gradient(${ dir }, ${ colorStops.join(", ") })`; 3 | } 4 | 5 | export function repeatingLinear(dir, colorStops) { 6 | return `repeating-linear-gradient(${ dir }, ${ colorStops.join(", ") })`; 7 | } 8 | -------------------------------------------------------------------------------- /universal/app/styles/layout.js: -------------------------------------------------------------------------------- 1 | import { rem } from "./scales"; 2 | 3 | export const maxContentWidth = rem(34); 4 | -------------------------------------------------------------------------------- /universal/app/styles/paddings.js: -------------------------------------------------------------------------------- 1 | import { px } from "./scales"; 2 | 3 | export const paddingRegular = px(14); 4 | export const paddingBigger = px(15); 5 | -------------------------------------------------------------------------------- /universal/app/styles/reset.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | 3 | css.global("*", { 4 | margin: 0, 5 | padding: 0, 6 | }); 7 | 8 | css.global("input", { 9 | border: "none", 10 | background: "none", 11 | }); 12 | -------------------------------------------------------------------------------- /universal/app/styles/scales.js: -------------------------------------------------------------------------------- 1 | const pxBase = 2; 2 | const pxRatio = 6 / 5; 3 | const remBase = 2 / 16; 4 | const remRatio = 6 / 5; 5 | const regularDevicePixelRatio = 2; 6 | 7 | export function px(factor) { 8 | const size = Math.pow(pxRatio, factor) * pxBase; 9 | 10 | // Round to whole device pixels 11 | return Math.floor(size * regularDevicePixelRatio) / regularDevicePixelRatio; 12 | } 13 | 14 | export function rem(factor) { 15 | return Math.pow(remRatio, factor) * remBase; 16 | } 17 | -------------------------------------------------------------------------------- /universal/app/styles/timing.js: -------------------------------------------------------------------------------- 1 | export function msToSeconds(ms) { 2 | return ms / 1000; 3 | } 4 | -------------------------------------------------------------------------------- /universal/app/styles/type/latoLight.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import woff2 from "../../assets/fonts/latoLatinLight.woff2"; 3 | import woff from "../../assets/fonts/latoLatinLight.woff"; 4 | import ttf from "../../assets/fonts/latoLatinLight.ttf"; 5 | 6 | const fontStyles = { 7 | fontWeight: 200, 8 | fontStyle: "normal", 9 | }; 10 | 11 | const fontFamily = css.fontFace({ 12 | fontFamily: "Lato", 13 | ...fontStyles, 14 | src: `local("Lato Light"), 15 | url("${ woff2 }") format("woff2"), 16 | url("${ woff }") format("woff"), 17 | url("${ ttf }") format("truetype")`, 18 | }); 19 | 20 | export default { 21 | fontFamily, 22 | ...fontStyles, 23 | }; 24 | 25 | -------------------------------------------------------------------------------- /universal/app/styles/type/nexaHeavy.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import woff2 from "../../assets/fonts/nexaHeavyWebfont.woff2"; 3 | import woff from "../../assets/fonts/nexaHeavyWebfont.woff"; 4 | import ttf from "../../assets/fonts/nexaHeavyWebfont.ttf"; 5 | 6 | const fontStyles = { 7 | fontWeight: 900, 8 | fontStyle: "normal", 9 | }; 10 | 11 | const fontFamily = css.fontFace({ 12 | fontFamily: "Nexa", 13 | ...fontStyles, 14 | src: `local("Nexa Heavy"), 15 | url("${ woff2 }") format("woff2"), 16 | url("${ woff }") format("woff"), 17 | url("${ ttf }") format("truetype")`, 18 | }); 19 | 20 | export default { 21 | fontFamily, 22 | ...fontStyles, 23 | }; 24 | -------------------------------------------------------------------------------- /universal/app/styles/type/nexaXBold.js: -------------------------------------------------------------------------------- 1 | import { css } from "glamor"; 2 | import woff2 from "../../assets/fonts/nexaExtraboldWebfont.woff2"; 3 | import woff from "../../assets/fonts/nexaExtraboldWebfont.woff"; 4 | import ttf from "../../assets/fonts/nexaExtraboldWebfont.ttf"; 5 | 6 | const fontStyles = { 7 | fontWeight: 700, 8 | fontStyle: "normal", 9 | }; 10 | 11 | const fontFamily = css.fontFace({ 12 | fontFamily: "Nexa", 13 | ...fontStyles, 14 | src: `local("Nexa XBold"), 15 | url("${ woff2 }") format("woff2"), 16 | url("${ woff }") format("woff"), 17 | url("${ ttf }") format("truetype")`, 18 | }); 19 | 20 | export default { 21 | fontFamily, 22 | ...fontStyles, 23 | }; 24 | -------------------------------------------------------------------------------- /universal/app/styles/typoSizes.js: -------------------------------------------------------------------------------- 1 | import { rem } from "./scales"; 2 | 3 | export const regularFontSize = rem(12); 4 | export const regularLineHeight = rem(14); 5 | export const regularMaxWidth = rem(32); 6 | 7 | export const headlineFontSize = rem(14); 8 | export const headlineLineHeight = rem(15); 9 | export const headlineMaxWidth = rem(30); 10 | 11 | export const asideFontSize = rem(11); 12 | export const asideLineHeight = rem(12); 13 | export const asideMaxWidth = rem(30); 14 | -------------------------------------------------------------------------------- /universal/app/styles/zIndex.js: -------------------------------------------------------------------------------- 1 | export const header = 2; 2 | export const modal = 3; 3 | export const backdrop = 1; 4 | -------------------------------------------------------------------------------- /universal/app/util/addObjectKeys.js: -------------------------------------------------------------------------------- 1 | export default function addObjectKeys(obj, propName) { 2 | Object.keys(obj).forEach(key => { 3 | obj[key][propName] = key; 4 | }); 5 | 6 | return obj; 7 | } 8 | -------------------------------------------------------------------------------- /universal/app/util/filterProps.js: -------------------------------------------------------------------------------- 1 | export default function filterProps(allProps, blacklist) { 2 | const filteredProps = {}; 3 | 4 | Object.keys(allProps) 5 | .filter(key => blacklist.indexOf(key) === -1) 6 | .forEach(key => (filteredProps[key] = allProps[key])); 7 | 8 | return filteredProps; 9 | } 10 | -------------------------------------------------------------------------------- /universal/app/util/has.js: -------------------------------------------------------------------------------- 1 | const hasOwnProperty = Object.prototype.hasOwnProperty; 2 | 3 | export default function has(obj, key) { 4 | return hasOwnProperty.call(obj, key); 5 | } 6 | -------------------------------------------------------------------------------- /universal/app/util/htmlEntities.js: -------------------------------------------------------------------------------- 1 | export const nbsp = "\u00a0"; 2 | -------------------------------------------------------------------------------- /universal/app/util/renderUrl.js: -------------------------------------------------------------------------------- 1 | import querystring from "querystring"; 2 | 3 | export default function renderUrl(urlPattern, params) { 4 | if (params === null || params === undefined) { 5 | return urlPattern; 6 | } 7 | 8 | let url = typeof urlPattern === "string" ? urlPattern : ""; 9 | const searchParams = {}; 10 | 11 | Object.keys(params).forEach(key => { 12 | const pattern = ":" + key; 13 | const patternIdx = url.indexOf(pattern); 14 | 15 | if (patternIdx === -1) { 16 | searchParams[key] = params[key]; 17 | } else { 18 | url = url.slice(0, patternIdx - 1) + params[key] + url.slice(patternIdx + pattern.length); 19 | } 20 | }); 21 | 22 | const paramString = querystring.stringify(searchParams); 23 | 24 | if (paramString === "") { 25 | return url; 26 | } 27 | 28 | return url + "?" + paramString; 29 | } 30 | -------------------------------------------------------------------------------- /universal/app/util/statusCodes.js: -------------------------------------------------------------------------------- 1 | export const MOVED_PERMANENTLY = 301; 2 | export const FOUND = 302; 3 | export const SEE_OTHER = 303; 4 | export const TEMPORARY_REDIRECT = 307; 5 | export const PERMANENT_REDIRECT = 308; 6 | 7 | const redirectStatusCodes = [MOVED_PERMANENTLY, FOUND, SEE_OTHER, TEMPORARY_REDIRECT, PERMANENT_REDIRECT]; 8 | 9 | export function isRedirect(statusCode) { 10 | return redirectStatusCodes.some(code => code === statusCode); 11 | } 12 | -------------------------------------------------------------------------------- /universal/config/server.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "hostname": "localhost", 4 | "devServerPort": 8080, 5 | "bodyLimit": "100kb", 6 | "corsHeaders": ["Link"], 7 | "responseDelay": 300, 8 | "session": { 9 | "name": "app.id", 10 | "cookie": { 11 | "maxAge": 2592000000 12 | }, 13 | "resave": false, 14 | "rolling": true, 15 | "saveUninitialized": false, 16 | "secret": "Universal JavaScript!" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /universal/server/config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | const pathToConfig = path.resolve(process.cwd(), "config", "server"); 4 | const config = require(pathToConfig); 5 | 6 | config.hostname = config.hostname || "localhost"; 7 | config.port = process.env.PORT || config.port; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /universal/server/dummyData/generate.js: -------------------------------------------------------------------------------- 1 | import filledArray from "filled-array"; 2 | import faker from "faker"; 3 | 4 | function posts() { 5 | return filledArray( 6 | i => ({ 7 | id: faker.random.uuid(), 8 | title: faker.lorem.sentence(), 9 | content: faker.lorem.paragraphs(), 10 | author: faker.name.findName(), 11 | published: faker.date.past(), 12 | starred: Math.floor(Math.random() * 100), 13 | image: `/postImage${ i % 4 + 1 }.jpg`, 14 | }), 15 | 30 16 | ); 17 | } 18 | 19 | posts(); 20 | -------------------------------------------------------------------------------- /universal/server/dummyData/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "353e4bdf-7436-41c1-a25c-25f0667692d9", 4 | "name": "jhnns", 5 | "image": "userImage.jpg" 6 | } 7 | ] -------------------------------------------------------------------------------- /universal/server/env.js: -------------------------------------------------------------------------------- 1 | const env = process.env.NODE_ENV || "development"; 2 | 3 | export const isProd = env === "production"; 4 | export const isDev = isProd === false; 5 | 6 | export default env; 7 | -------------------------------------------------------------------------------- /universal/tools/webpack/ResolveEffectPlugin.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | const pathToEffects = path.resolve(__dirname, "..", "..", "app", "effects"); 4 | 5 | class ResolverPlugin { 6 | constructor(options) { 7 | this.options = options; 8 | } 9 | rewritePath([all, effectName]) { 10 | return path.resolve(pathToEffects, effectName, effectName + "." + this.options.target); 11 | } 12 | apply(resolver) { 13 | resolver.plugin("described-resolve", (request, callback) => { 14 | const requestPath = request.__innerRequest; 15 | const pathMatch = /[/\\]effects[/\\]([^/\\]+)$/.exec(requestPath); 16 | 17 | if (pathMatch === null) { 18 | callback(); 19 | 20 | return; 21 | } 22 | 23 | resolver.doResolve( 24 | "resolve", 25 | Object.assign({}, request, { 26 | request: this.rewritePath(pathMatch), 27 | }), 28 | "resolved effect", 29 | callback 30 | ); 31 | }); 32 | } 33 | } 34 | 35 | export default class ResolveEffectPlugin { 36 | constructor(options) { 37 | if (options.target === undefined) { 38 | throw new Error("No target given"); 39 | } 40 | this.options = options; 41 | } 42 | 43 | apply(compiler) { 44 | compiler.plugin("after-resolvers", () => { 45 | compiler.resolvers.normal.apply(new ResolverPlugin(this.options)); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /universal/tools/webpack/WriteAssetsJsonPlugin.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { assetsJson as pathToAssetJson } from "../../app/server/paths"; 3 | 4 | export default class WriteAssetsJsonPlugin { 5 | apply(compiler) { 6 | compiler.plugin("after-emit", (compilation, done) => { 7 | const publicPath = compiler.options.output.publicPath || "/"; 8 | const stats = compilation.getStats().toJson(); 9 | const assets = stats.chunks.reduce((assets, chunk) => { 10 | assets[chunk.names[0]] = chunk.files.map(file => publicPath + file); 11 | 12 | return assets; 13 | }, {}); 14 | 15 | fs.writeFile(pathToAssetJson, JSON.stringify(assets), done); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /universal/tools/webpack/exportCssLoader.js: -------------------------------------------------------------------------------- 1 | const exportCss = `module.exports = (({ renderStatic }, oldExports) => { 2 | const oldKeys = Object.keys(oldExports); 3 | const locals = {}; 4 | const { css } = renderStatic(() => { 5 | oldKeys.forEach(key => { 6 | const exportValue = oldExports[key]; 7 | 8 | if (exportValue !== undefined && exportValue !== null) { 9 | locals[key] = exportValue; 10 | } 11 | }); 12 | 13 | return ""; 14 | }); 15 | const newExports = [[module.id, css]]; 16 | 17 | Object.assign(newExports, oldExports); 18 | newExports.locals = locals; 19 | 20 | return newExports; 21 | })(require("glamor-server"), module.exports);`; 22 | 23 | module.exports = function (source, sourceMaps) { 24 | this.callback(null, source + ";" + exportCss, sourceMaps); 25 | }; 26 | --------------------------------------------------------------------------------