├── img
└── loading.gif
├── .gitignore
├── test
├── stubs
│ └── api
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ └── server.js
├── Dockerfile
├── package.json
├── nightwatch.json
├── index.story.test.html
├── index.test.html
├── features.headline.js
└── features.story.js
├── js
├── components
│ ├── Title.jsx
│ ├── Loading.jsx
│ ├── EmptyStories.jsx
│ ├── Layout.jsx
│ ├── ComponentBuilder.jsx
│ ├── Counter.jsx
│ ├── HeadlineItem.jsx
│ ├── StoryItem.jsx
│ ├── Story.jsx
│ └── Viewer.jsx
├── Downloader.js
├── Storage.js
├── App.js
└── Keyboard.js
├── Dockerfile
├── Makefile
├── .eslintrc.json
├── webpack.config.js
├── docker-compose.yml
├── server.js
├── index.html
├── dist
├── index.html
└── minimal_viewer-0.3.1.css
├── LICENSE
├── package.json
├── css
└── app.css
└── README.md
/img/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MiguelBel/MinimalViewer/HEAD/img/loading.gif
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | test/screenshots/
2 | node_modules
3 | npm-debug.log
4 | *.backup
5 | minimal_viewer.js
6 |
--------------------------------------------------------------------------------
/test/stubs/api/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mhart/alpine-node
2 | COPY package.json /api/package.json
3 | WORKDIR /api
4 | RUN npm install
5 |
--------------------------------------------------------------------------------
/test/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:7.1.0
2 | RUN mkdir -p /functional_test_suite
3 | COPY package.json /functional_test_suite/package.json
4 | WORKDIR /functional_test_suite
5 | RUN npm install
6 |
--------------------------------------------------------------------------------
/test/stubs/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "stub_api",
3 | "version": "0.1.0",
4 | "description": "Stub API",
5 | "main": "server.js",
6 | "dependencies": {
7 | "express": "^4.14.0"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/js/components/Title.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Title = ({ text }) => (
4 |
5 |
{text}
6 |
7 | )
8 |
9 | export default Title
10 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mhart/alpine-node
2 | RUN mkdir -p /minimal_viewer
3 | COPY package.json /minimal_viewer/package.json
4 | COPY webpack.config.js /minimal_viewer/webpack.config.js
5 | WORKDIR /minimal_viewer
6 | RUN npm install
7 |
--------------------------------------------------------------------------------
/test/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "functional_suite",
3 | "version": "0.1.0",
4 | "description": "Functional test suite",
5 | "main": "test.js",
6 | "scripts": {
7 | "test": "nightwatch *.js"
8 | },
9 | "dependencies": {
10 | "nightwatch": "^0.9.8"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/js/components/Loading.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Loading = ({ color }) => (
4 |
5 |
6 | Loading...
7 |
8 |
9 | )
10 |
11 | export default Loading
12 |
--------------------------------------------------------------------------------
/js/components/EmptyStories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const EmptyStories = () => (
4 |
5 |
6 |
Woops! There is nothing else for you, yet...
7 |
8 |
9 | )
10 |
11 | export default EmptyStories
12 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | create_version:
2 | docker-compose exec minimal_viewer npm run build
3 | rm dist/minimal_viewer-*.js | true
4 | rm dist/minimal_viewer-*.css | true
5 | mv dist/minimal_viewer.js dist/minimal_viewer-$$(npm run get-version --silent).js
6 | mv dist/minimal_viewer.css dist/minimal_viewer-$$(npm run get-version --silent).css
--------------------------------------------------------------------------------
/js/components/Layout.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Title from './Title'
3 |
4 | const Layout = ({ children, color, id, title }) => (
5 |
6 |
7 |
8 | {children}
9 |
10 | )
11 |
12 | export default Layout
13 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "standard",
4 | "standard-react",
5 | "plugin:react/recommended"
6 | ],
7 | "env": {
8 | "browser": true,
9 | "mocha": true,
10 | "node": true
11 | },
12 | "plugins": [
13 | "standard",
14 | "react"
15 | ],
16 | "rules": {
17 | "react/sort-comp": "error",
18 | "react/sort-prop-types": "error"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/test/nightwatch.json:
--------------------------------------------------------------------------------
1 | {
2 | "src_folders": ["."],
3 | "output_folder": false,
4 | "test_settings" : {
5 | "default" : {
6 | "launch_url": "http://minimal_viewer",
7 | "selenium_port": 4444,
8 | "selenium_host": "chromedriver",
9 | "desiredCapabilities": {
10 | "browserName": "chrome",
11 | "javascriptEnabled": true
12 | },
13 | "screenshots": {
14 | "enabled": true,
15 | "path": "screenshots/"
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/js/Downloader.js:
--------------------------------------------------------------------------------
1 | import jmespath from 'jmespath'
2 |
3 | const Downloader = {
4 | create (url, fn, root) {
5 | this.API_URL = url
6 | this.download_stories(fn, root)
7 | },
8 |
9 | download_stories (fn, root) {
10 | fetch(this.API_URL).then(response => {
11 | response.json().then(parsed => {
12 | if (root) {
13 | fn(jmespath.search(parsed, root))
14 | } else {
15 | fn(parsed)
16 | }
17 | })
18 | })
19 | }
20 | }
21 |
22 | export default Downloader
23 |
--------------------------------------------------------------------------------
/js/components/ComponentBuilder.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import EmptyStories from './EmptyStories'
3 | import Loading from './Loading'
4 | import Story from './Story'
5 |
6 | const ComponentBuilder = ({ color, isEmpty, isLoading, ...story }) => {
7 |
8 | if (isEmpty) return (
9 |
10 | )
11 |
12 | if (isLoading) return (
13 |
14 | )
15 |
16 | return (
17 |
21 | )
22 | }
23 |
24 | export default ComponentBuilder
25 |
--------------------------------------------------------------------------------
/js/components/Counter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Counter = ({ color, current, total, progress }) => (
4 |
5 |
6 |
7 |
8 |
9 | { current }
10 | /
11 | { total }
12 |
13 |
14 |
15 |
16 | )
17 |
18 | export default Counter
19 |
--------------------------------------------------------------------------------
/js/Storage.js:
--------------------------------------------------------------------------------
1 | const EMPTY = []
2 |
3 | const Storage = {
4 | retrieve (identifier) {
5 | const items = JSON.parse(
6 | localStorage.getItem(`viewer_${identifier}`)
7 | )
8 |
9 | return items || EMPTY
10 | },
11 |
12 | store (identifier, element) {
13 | const store = this.retrieve(identifier)
14 | const isAlreadyRegistered = store.includes(element)
15 |
16 | if (isAlreadyRegistered) return
17 |
18 | store.push(element)
19 | localStorage.setItem(
20 | `viewer_${identifier}`,
21 | JSON.stringify(store)
22 | )
23 | }
24 | }
25 |
26 | export default Storage
27 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 |
3 | module.exports ={
4 | entry: ['whatwg-fetch', './js/App.js'],
5 | output: {
6 | filename: 'minimal_viewer.js',
7 | path: '/minimal_viewer/dist/',
8 | library: 'MinimalViewer'
9 | },
10 | module: {
11 | loaders: [
12 | {
13 | test: /\.jsx?$/,
14 | loader: 'babel-loader',
15 | exclude: /node_modules/,
16 | query: {
17 | presets: ['es2015', 'stage-0', 'react']
18 | }
19 | },
20 | ]
21 | },
22 | resolve: {
23 | extensions: ['', '.js', '.jsx', '.json'],
24 | root: [
25 | path.resolve('./js')
26 | ]
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/test/stubs/api/server.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var app = express();
3 | var path = require('path');
4 |
5 | app.use(express.static(process.cwd()));
6 |
7 | app.get('/', function(req, res) {
8 | res.setHeader('Content-Type', 'application/json');
9 | res.setHeader('Access-Control-Allow-Origin', '*');
10 | var fixture = { 'wadus': { 'another_key': []} };
11 |
12 | for (var i = 1; i <= 30; i++) {
13 | fixture['wadus']['another_key'].push({
14 | "data": { title: 'Title ' + i, url: 'http://www.' + i + '.com', id: String(i), domain: 'www.' + i + '.com' }
15 | });
16 | }
17 |
18 | res.send(JSON.stringify(fixture));
19 | });
20 |
21 | app.listen(process.env.PORT, function () {
22 | console.log('[APP] Listening in port ' + process.env.PORT);
23 | })
--------------------------------------------------------------------------------
/js/components/HeadlineItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const HeadLineItem = ({ color, link, subtitle, title }) => {
4 | const handleMouseOver = (e) => {
5 | const { target: { style } } = e
6 |
7 | style.color = color
8 | }
9 |
10 | const handleMouseOut = (e) => {
11 | const { target: { style } } = e
12 |
13 | style.color = 'inherit'
14 | }
15 |
16 | return (
17 |
18 |
26 |
{subtitle}
27 |
28 | )
29 | }
30 |
31 | export default HeadLineItem
32 |
--------------------------------------------------------------------------------
/js/components/StoryItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const StoryItem = ({ color, link, subtitle, title }) => {
4 | const handleMouseOver = (e) => {
5 | const { target: { style } } = e
6 |
7 | style.color = color
8 | }
9 |
10 | const handleMouseOut = (e) => {
11 | const { target: { style } } = e
12 |
13 | style.color = 'inherit'
14 | }
15 |
16 | return (
17 |
18 |
26 |
27 |
{subtitle}
28 |
29 | )
30 | }
31 |
32 | export default StoryItem
33 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | chromedriver:
2 | image: selenium/standalone-chrome
3 | links:
4 | - minimal_viewer
5 | - api_stub
6 | ports:
7 | - "4444:4444"
8 |
9 | minimal_viewer:
10 | build: .
11 | environment:
12 | - PORT=9300
13 | - URL=http://localhost:9400
14 | ports:
15 | - 9300:9300
16 | links:
17 | - api_stub
18 | volumes:
19 | - '.:/minimal_viewer'
20 | - '/minimal_viewer/node_modules'
21 | command: node server.js
22 |
23 | api_stub:
24 | build: ./test/stubs/api
25 | environment:
26 | - PORT=9400
27 | ports:
28 | - 9400:9400
29 | volumes:
30 | - './test/stubs/api:/api'
31 | - '/api/node_modules'
32 | command: node server.js
33 |
34 | functional_test_suite:
35 | build: ./test
36 | links:
37 | - chromedriver
38 | volumes:
39 | - './test/:/functional_test_suite'
40 | - '/functional_test_suite/node_modules'
41 | command: sleep infinity
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var webpack = require('webpack');
3 | var path = require('path');
4 | var config = require('./webpack.config');
5 |
6 | var app = express();
7 | var compiler = webpack(config);
8 |
9 | app.use(require('webpack-dev-middleware')(compiler, {
10 | stats: {
11 | colors: true
12 | }
13 | }));
14 |
15 | app.use(require('webpack-hot-middleware')(compiler));
16 | app.use(express.static(process.cwd()));
17 |
18 | app.get('/', function(req, res) {
19 | res.sendFile(path.join(__dirname + '/index.html'));
20 | });
21 |
22 | app.get('/test', function(req, res) {
23 | res.sendFile(path.join(__dirname + '/test/index.test.html'));
24 | });
25 |
26 | app.get('/test_story', function(req, res) {
27 | res.sendFile(path.join(__dirname + '/test/index.story.test.html'));
28 | });
29 |
30 | app.listen(process.env.PORT, function () {
31 | console.log('[APP] Listening in port ' + process.env.PORT);
32 | })
33 |
--------------------------------------------------------------------------------
/test/index.story.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Minimal Viewer Test
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/test/index.test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Minimal Viewer Test
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Minimal Viewer
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/dist/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Minimal Viewer
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/js/components/Story.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import jmespath from 'jmespath'
3 |
4 | import HeadlineItem from './HeadlineItem'
5 | import StoryItem from './StoryItem'
6 | import Counter from './Counter'
7 |
8 | const TEMPLATES = {
9 | 'headline': HeadlineItem,
10 | 'story': StoryItem
11 | }
12 | const DEFAULT_TEMPLATE = HeadlineItem
13 |
14 | const Story = ({ color, queueIndex, queueSize, relations, story, type }) => {
15 | const Template = TEMPLATES[type] || DEFAULT_TEMPLATE
16 | let progress = (queueIndex / queueSize) * 100
17 |
18 | return (
19 |
20 |
26 |
32 |
33 | )
34 | }
35 |
36 | export default Story
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | (The MIT License)
2 |
3 | Copyright (c) 2016 Hacker News Viewer contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | 'Software'), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hackernewsviewer",
3 | "version": "0.3.1",
4 | "description": "Simple HackerNews viewer",
5 | "main": "app.js",
6 | "scripts": {
7 | "build": "webpack -g ; cp index.html dist/index.html ; cp css/app.css dist/minimal_viewer.css",
8 | "lint": "eslint js",
9 | "get-version": "echo $npm_package_version"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/MiguelBel/HackerNewsViewer.git"
14 | },
15 | "author": "",
16 | "license": "MIT",
17 | "bugs": {
18 | "url": "https://github.com/MiguelBel/HackerNewsViewer/issues"
19 | },
20 | "homepage": "https://github.com/MiguelBel/HackerNewsViewer#readme",
21 | "dependencies": {
22 | "babel-core": "^6.18.0",
23 | "babel-loader": "^6.2.7",
24 | "babel-preset-es2015": "^6.18.0",
25 | "babel-preset-react": "^6.16.0",
26 | "express": "^4.14.0",
27 | "postal": "^2.0.4",
28 | "react": "^15.3.2",
29 | "react-dom": "^15.3.2",
30 | "webpack": "^1.13.3",
31 | "webpack-dev-middleware": "^1.8.4",
32 | "webpack-hot-middleware": "^2.13.2",
33 | "whatwg-fetch": "^2.0.0",
34 | "jmespath": "0.15.0"
35 | },
36 | "devDependencies": {
37 | "babel-preset-stage-0": "^6.16.0",
38 | "eslint": "^3.11.0",
39 | "eslint-config-airbnb": "^13.0.0",
40 | "eslint-config-standard": "^6.2.1",
41 | "eslint-config-standard-jsx": "^3.2.0",
42 | "eslint-config-standard-react": "^4.2.0",
43 | "eslint-plugin-import": "^2.2.0",
44 | "eslint-plugin-jsx-a11y": "^3.0.1",
45 | "eslint-plugin-promise": "^3.4.0",
46 | "eslint-plugin-react": "^6.7.1",
47 | "eslint-plugin-standard": "^2.0.1"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/js/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react'
2 | import ReactDOM from 'react-dom'
3 | import postal from 'postal'
4 |
5 | import Keyboard from './Keyboard'
6 |
7 | import Viewer from 'components/Viewer'
8 |
9 | const INITIAL_INDEX = 0
10 |
11 | class App extends Component {
12 | constructor (props) {
13 | super(props)
14 |
15 | this.state = {
16 | currentIndex: INITIAL_INDEX
17 | }
18 |
19 | this._changeViewer = this._changeViewer.bind(this)
20 | }
21 |
22 | componentDidMount () {
23 | const channel = postal.channel()
24 | this.subscription = channel.subscribe('action_triggered', this._changeViewer)
25 | }
26 |
27 | componentWillUnmount () {
28 | this.subscription.unsubscribe()
29 | }
30 |
31 | _changeViewer ({ name }) {
32 | const { currentIndex } = this.state
33 | const { viewers } = this.props
34 | let index
35 |
36 | switch (name) {
37 | case 'next_viewer':
38 | index = currentIndex + 1
39 | break
40 |
41 | case 'previous_viewer':
42 | index = currentIndex - 1
43 | break
44 | }
45 |
46 | if (viewers[index]) {
47 | this.setState({ currentIndex: index })
48 | }
49 | }
50 |
51 | render () {
52 | const { viewers } = this.props
53 | const { currentIndex } = this.state
54 | const viewer = viewers[currentIndex]
55 |
56 | Keyboard.define(viewer.identifier)
57 |
58 | return (
59 |
69 | )
70 | }
71 |
72 | }
73 |
74 | const { array } = PropTypes
75 | App.propTypes = {
76 | viewers: array.isRequired
77 | }
78 |
79 | module.exports = {
80 | initialize: function (selector, configuration) {
81 | ReactDOM.render(
82 | ,
83 | document.querySelector(selector)
84 | )
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/js/Keyboard.js:
--------------------------------------------------------------------------------
1 | import postal from 'postal'
2 |
3 | const [LEFT, UP, RIGHT, DOWN, ENTER, SPACE] = [37, 38, 39, 40, 13, 32]
4 | const VALID_KEY_CODES = [LEFT, UP, RIGHT, DOWN, ENTER, SPACE]
5 | const Keyboard = {
6 | define (identifier) {
7 | this.identifier = identifier
8 |
9 | document.onkeydown = (e) => {
10 | const { keyCode } = e
11 | if (!VALID_KEY_CODES.includes(keyCode)) return
12 | e.preventDefault()
13 |
14 | switch (keyCode) {
15 | case UP:
16 | return this._triggerAction('previous_viewer')
17 | case DOWN:
18 | return this._triggerAction('next_viewer')
19 | case LEFT:
20 | return this._triggerAction('prev')
21 | case RIGHT:
22 | return this._triggerAction('next')
23 | case ENTER:
24 | case SPACE:
25 | return this._triggerAction('open')
26 | }
27 | }
28 |
29 | window.addEventListener('touchstart', this._handleTouchStart.bind(this), false)
30 | window.addEventListener('touchmove', this._handleTouchMove.bind(this), false)
31 | window.addEventListener('touchend', this._handleTouchEnd.bind(this), false)
32 | },
33 |
34 | _triggerAction (name) {
35 | const channel = postal.channel()
36 |
37 | channel.publish(
38 | 'action_triggered',
39 | {
40 | name,
41 | element: this.identifier
42 | }
43 | )
44 | },
45 |
46 | _handleTouchStart (e) {
47 | this.touch = {
48 | start: {
49 | x: e.touches[0].clientX,
50 | y: e.touches[0].clientY
51 | },
52 | current: {}
53 | }
54 | },
55 |
56 | _handleTouchMove (e) {
57 | const story = document.querySelector('#story-link')
58 | const xDiff = e.touches[0].clientX - this.touch.start.x
59 |
60 | this.touch.current = {
61 | x: e.touches[0].clientX,
62 | y: e.touches[0].clientY
63 | }
64 |
65 | story.style.transform = `translateX(${xDiff}px)`
66 | },
67 |
68 | _handleTouchEnd () {
69 | if (!this.touch.current.x) {
70 | return this._triggerAction('open')
71 | }
72 |
73 | const story = document.querySelector('#story-link')
74 | story.style.transform = ''
75 |
76 | if (this.touch.current.x < this.touch.start.x) {
77 | return this._triggerAction('next')
78 | } else {
79 | return this._triggerAction('prev')
80 | }
81 | }
82 | }
83 |
84 | export default Keyboard
85 |
--------------------------------------------------------------------------------
/test/features.headline.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'Lateral movement': function (browser) {
3 | App.enter(browser);
4 | App.expect_to_have_item(1, browser);
5 |
6 | App.move_right(browser);
7 | App.expect_to_have_item(2, browser);
8 |
9 | App.move_right(browser);
10 | App.expect_to_have_item(3, browser);
11 |
12 | App.move_left(browser);
13 | App.move_left(browser);
14 | App.expect_to_have_item(1, browser);
15 |
16 | App.enter(browser);
17 | App.expect_to_have_item(4, browser);
18 | App.enter(browser);
19 | App.expect_to_have_item(5, browser);
20 |
21 | browser.end();
22 | },
23 |
24 | 'Do not show already shown': function (browser) {
25 | App.enter(browser);
26 | App.expect_to_have_item(1, browser)
27 | App.enter(browser);
28 | App.expect_to_have_item(2, browser)
29 |
30 | browser.end();
31 | },
32 |
33 | 'Counter': function (browser) {
34 | var total = 30;
35 |
36 | App.enter(browser);
37 | App.expect_counter_to_have(1, total, browser);
38 |
39 | App.move_right(browser);
40 | App.expect_counter_to_have(2, total, browser);
41 |
42 | App.enter(browser);
43 | var total = 28;
44 | App.expect_counter_to_have(1, total, browser);
45 | },
46 |
47 | 'Title by configuration': function (browser) {
48 | App.enter(browser);
49 | App.expect_to_have_title('TestViewer', browser);
50 | },
51 |
52 | 'Color by configuration': function(browser) {
53 | App.enter(browser);
54 | App.expect_to_have_color('black', browser);
55 | }
56 | };
57 |
58 | App = {
59 | app_url: 'http://minimal_viewer:9300/test',
60 |
61 | enter: function(browser) {
62 | browser
63 | .url(this.app_url)
64 | .waitForElementVisible('#story-url', 1000)
65 | },
66 |
67 | move_right: function(browser) {
68 | browser.keys(browser.Keys['RIGHT_ARROW'])
69 | },
70 |
71 | move_left: function(browser) {
72 | browser.keys(browser.Keys['LEFT_ARROW'])
73 | },
74 |
75 | expect_to_have_item: function(number, browser) {
76 | title = 'Title ' + number;
77 | url = 'http://www.' + number + '.com/';
78 | browser.assert.containsText('#story-url', title);
79 | browser.expect.element('#story-url').to.have.attribute('href').which.equals(url);
80 | browser.expect.element('#story-url').to.have.attribute('target').which.equals('_blank');
81 | },
82 |
83 | expect_counter_to_have: function(number, total, browser) {
84 | browser.assert.containsText('#stories-counter', number + '/' + total)
85 | },
86 |
87 | expect_to_have_title: function(title, browser) {
88 | browser.assert.containsText('#title', title);
89 | },
90 |
91 | expect_to_have_color: function(color, browser) {
92 | style = 'color: ' + color + ';'
93 | browser.expect.element('#test_viewer').to.have.attribute('style').which.equals(style);
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/test/features.story.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'Lateral movement': function (browser) {
3 | App.enter(browser);
4 | App.expect_to_have_item(1, browser);
5 |
6 | App.move_right(browser);
7 | App.expect_to_have_item(2, browser);
8 |
9 | App.move_right(browser);
10 | App.expect_to_have_item(3, browser);
11 |
12 | App.move_left(browser);
13 | App.move_left(browser);
14 | App.expect_to_have_item(1, browser);
15 |
16 | App.enter(browser);
17 | App.expect_to_have_item(4, browser);
18 | App.enter(browser);
19 | App.expect_to_have_item(5, browser);
20 |
21 | browser.end();
22 | },
23 |
24 | 'Do not show already shown': function (browser) {
25 | App.enter(browser);
26 | App.expect_to_have_item(1, browser)
27 | App.enter(browser);
28 | App.expect_to_have_item(2, browser)
29 |
30 | browser.end();
31 | },
32 |
33 | 'Counter': function (browser) {
34 | var total = 30;
35 |
36 | App.enter(browser);
37 | App.expect_counter_to_have(1, total, browser);
38 |
39 | App.move_right(browser);
40 | App.expect_counter_to_have(2, total, browser);
41 |
42 | App.enter(browser);
43 | var total = 28;
44 | App.expect_counter_to_have(1, total, browser);
45 | },
46 |
47 | 'Title by configuration': function (browser) {
48 | App.enter(browser);
49 | App.expect_to_have_title('TestViewer', browser);
50 | },
51 |
52 | 'Color by configuration': function(browser) {
53 | App.enter(browser);
54 | App.expect_to_have_color('black', browser);
55 | }
56 | };
57 |
58 | App = {
59 | app_url: 'http://minimal_viewer:9300/test_story',
60 |
61 | enter: function(browser) {
62 | browser
63 | .url(this.app_url)
64 | .waitForElementVisible('#story-url', 1000)
65 | },
66 |
67 | move_right: function(browser) {
68 | browser.keys(browser.Keys['RIGHT_ARROW'])
69 | },
70 |
71 | move_left: function(browser) {
72 | browser.keys(browser.Keys['LEFT_ARROW'])
73 | },
74 |
75 | expect_to_have_item: function(number, browser) {
76 | title = 'Title ' + number;
77 | url = 'http://www.' + number + '.com/';
78 | browser.assert.containsText('#story-url', title);
79 | browser.expect.element('#story-url').to.have.attribute('href').which.equals(url);
80 | browser.expect.element('#story-url').to.have.attribute('target').which.equals('_blank');
81 | },
82 |
83 | expect_counter_to_have: function(number, total, browser) {
84 | browser.assert.containsText('#stories-counter', number + '/' + total)
85 | },
86 |
87 | expect_to_have_title: function(title, browser) {
88 | browser.assert.containsText('#title', title);
89 | },
90 |
91 | expect_to_have_color: function(color, browser) {
92 | style = 'color: ' + color + ';'
93 | browser.expect.element('#test_viewer').to.have.attribute('style').which.equals(style);
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/css/app.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Roboto');
2 |
3 | body {
4 | font-family: 'Roboto', serif;
5 | line-height: 24px;
6 | margin: 0;
7 | }
8 |
9 | .full-screen {
10 | height: 100%;
11 | width: 100%;
12 | overflow: hidden;
13 | position: relative;
14 | display: none;
15 | }
16 |
17 | .visible {
18 | display: block;
19 | }
20 |
21 | .empty-container {
22 | width: 100%;
23 | height: 100%;
24 | }
25 |
26 | .empty-message {
27 | text-align: center;
28 | position: relative;
29 | top: 40%;
30 | -webkit-transform: translateY(-40%);
31 | -ms-transform: translateY(-40%);
32 | transform: translateY(-40%);
33 | }
34 |
35 | .container {
36 | text-align: center;
37 | max-width: 800px;
38 | margin: 0 auto;
39 | position: relative;
40 | top: 40%;
41 | -webkit-transform: translateY(-40%);
42 | -ms-transform: translateY(-40%);
43 | transform: translateY(-40%);
44 | }
45 |
46 | .empty-message p {
47 | text-decoration: none;
48 | font-size: 40px;
49 | }
50 |
51 | a {
52 | text-decoration: none;
53 | font-size: 30px;
54 | color: inherit;
55 | }
56 |
57 | .counter {
58 | position: absolute;
59 | bottom: 50px;
60 | text-align: center;
61 | width: 100%;
62 | }
63 |
64 | .counter .indicator{
65 | font-size: 22px;
66 | color: black;
67 | margin: 0 auto;
68 | max-width: 150px;
69 | border-top: 2px solid black;
70 | }
71 |
72 | .counter .current {
73 | color: #2bde73;
74 | }
75 |
76 | .main-title {
77 | position: absolute;
78 | top: 20px;
79 | left: 20px;
80 | }
81 |
82 | .Title {
83 | margin: 0 auto;
84 | line-height: 40px;
85 | }
86 |
87 | .Headline .Title {
88 | text-align: center;
89 | }
90 |
91 | .Story .Title {
92 | text-align: left;
93 | }
94 |
95 | .Story .Separator {
96 | height: 2px;
97 | background-color: black;
98 | width: 150px;
99 | margin-top: 20px;
100 | }
101 |
102 | .Story .Subtitle {
103 | margin: 0 auto;
104 | margin-top: 15px;
105 | font-size: 16px;
106 | text-align: left;
107 | }
108 |
109 | .Headline .Subtitle {
110 | margin-left: auto;
111 | margin-right: auto;
112 | }
113 |
114 | .top-progress-bar {
115 | position: absolute;
116 | height: 6px;
117 | top: 0;
118 | display: block;
119 | width: 100%;
120 | margin-top: 0px;
121 | background-color: white;
122 | }
123 |
124 | /*
125 | Loader
126 | */
127 | .loader-container {
128 | position: fixed;
129 | top: 50%;
130 | left: 50%;
131 | transform: translate(-50%, -50%);
132 | }
133 |
134 | .loader,
135 | .loader:before,
136 | .loader:after {
137 | background-color: inherit;
138 | -webkit-animation: load1 1s infinite ease-in-out;
139 | animation: load1 1s infinite ease-in-out;
140 | width: 1em;
141 | height: 4em;
142 | }
143 | .loader {
144 | background-color: inherit;
145 | text-indent: -9999em;
146 | margin: 88px auto;
147 | position: relative;
148 | font-size: 11px;
149 | -webkit-transform: translateZ(0);
150 | -ms-transform: translateZ(0);
151 | transform: translateZ(0);
152 | -webkit-animation-delay: -0.16s;
153 | animation-delay: -0.16s;
154 | }
155 | .loader:before,
156 | .loader:after {
157 | position: absolute;
158 | top: 0;
159 | content: '';
160 | }
161 | .loader:before {
162 | left: -1.5em;
163 | -webkit-animation-delay: -0.32s;
164 | animation-delay: -0.32s;
165 | }
166 | .loader:after {
167 | left: 1.5em;
168 | }
169 | @-webkit-keyframes load1 {
170 | 0%,
171 | 80%,
172 | 100% {
173 | box-shadow: 0 0;
174 | height: 4em;
175 | }
176 | 40% {
177 | box-shadow: 0 -2em;
178 | height: 5em;
179 | }
180 | }
181 | @keyframes load1 {
182 | 0%,
183 | 80%,
184 | 100% {
185 | box-shadow: 0 0;
186 | height: 4em;
187 | }
188 | 40% {
189 | box-shadow: 0 -2em;
190 | height: 5em;
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/dist/minimal_viewer-0.3.1.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Roboto');
2 |
3 | body {
4 | font-family: 'Roboto', serif;
5 | line-height: 24px;
6 | margin: 0;
7 | }
8 |
9 | .full-screen {
10 | height: 100%;
11 | width: 100%;
12 | overflow: hidden;
13 | position: relative;
14 | display: none;
15 | }
16 |
17 | .visible {
18 | display: block;
19 | }
20 |
21 | .empty-container {
22 | width: 100%;
23 | height: 100%;
24 | }
25 |
26 | .empty-message {
27 | text-align: center;
28 | position: relative;
29 | top: 40%;
30 | -webkit-transform: translateY(-40%);
31 | -ms-transform: translateY(-40%);
32 | transform: translateY(-40%);
33 | }
34 |
35 | .container {
36 | text-align: center;
37 | max-width: 800px;
38 | margin: 0 auto;
39 | position: relative;
40 | top: 40%;
41 | -webkit-transform: translateY(-40%);
42 | -ms-transform: translateY(-40%);
43 | transform: translateY(-40%);
44 | }
45 |
46 | .empty-message p {
47 | text-decoration: none;
48 | font-size: 40px;
49 | }
50 |
51 | a {
52 | text-decoration: none;
53 | font-size: 30px;
54 | color: inherit;
55 | }
56 |
57 | .counter {
58 | position: absolute;
59 | bottom: 50px;
60 | text-align: center;
61 | width: 100%;
62 | }
63 |
64 | .counter .indicator{
65 | font-size: 22px;
66 | color: black;
67 | margin: 0 auto;
68 | max-width: 150px;
69 | border-top: 2px solid black;
70 | }
71 |
72 | .counter .current {
73 | color: #2bde73;
74 | }
75 |
76 | .main-title {
77 | position: absolute;
78 | top: 20px;
79 | left: 20px;
80 | }
81 |
82 | .Title {
83 | margin: 0 auto;
84 | line-height: 40px;
85 | }
86 |
87 | .Headline .Title {
88 | text-align: center;
89 | }
90 |
91 | .Story .Title {
92 | text-align: left;
93 | }
94 |
95 | .Story .Separator {
96 | height: 2px;
97 | background-color: black;
98 | width: 150px;
99 | margin-top: 20px;
100 | }
101 |
102 | .Story .Subtitle {
103 | margin: 0 auto;
104 | margin-top: 15px;
105 | font-size: 16px;
106 | text-align: left;
107 | }
108 |
109 | .Headline .Subtitle {
110 | margin-left: auto;
111 | margin-right: auto;
112 | }
113 |
114 | .top-progress-bar {
115 | position: absolute;
116 | height: 6px;
117 | top: 0;
118 | display: block;
119 | width: 100%;
120 | margin-top: 0px;
121 | background-color: white;
122 | }
123 |
124 | /*
125 | Loader
126 | */
127 | .loader-container {
128 | position: fixed;
129 | top: 50%;
130 | left: 50%;
131 | transform: translate(-50%, -50%);
132 | }
133 |
134 | .loader,
135 | .loader:before,
136 | .loader:after {
137 | background-color: inherit;
138 | -webkit-animation: load1 1s infinite ease-in-out;
139 | animation: load1 1s infinite ease-in-out;
140 | width: 1em;
141 | height: 4em;
142 | }
143 | .loader {
144 | background-color: inherit;
145 | text-indent: -9999em;
146 | margin: 88px auto;
147 | position: relative;
148 | font-size: 11px;
149 | -webkit-transform: translateZ(0);
150 | -ms-transform: translateZ(0);
151 | transform: translateZ(0);
152 | -webkit-animation-delay: -0.16s;
153 | animation-delay: -0.16s;
154 | }
155 | .loader:before,
156 | .loader:after {
157 | position: absolute;
158 | top: 0;
159 | content: '';
160 | }
161 | .loader:before {
162 | left: -1.5em;
163 | -webkit-animation-delay: -0.32s;
164 | animation-delay: -0.32s;
165 | }
166 | .loader:after {
167 | left: 1.5em;
168 | }
169 | @-webkit-keyframes load1 {
170 | 0%,
171 | 80%,
172 | 100% {
173 | box-shadow: 0 0;
174 | height: 4em;
175 | }
176 | 40% {
177 | box-shadow: 0 -2em;
178 | height: 5em;
179 | }
180 | }
181 | @keyframes load1 {
182 | 0%,
183 | 80%,
184 | 100% {
185 | box-shadow: 0 0;
186 | height: 4em;
187 | }
188 | 40% {
189 | box-shadow: 0 -2em;
190 | height: 5em;
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/js/components/Viewer.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react'
2 | import jmespath from 'jmespath'
3 | import postal from 'postal'
4 |
5 | import Downloader from '../Downloader'
6 | import Storage from '../Storage'
7 |
8 | import Layout from './Layout'
9 | import ComponentBuilder from './ComponentBuilder'
10 |
11 | const INITIAL_INDEX = -1
12 |
13 | class Viewer extends Component {
14 | constructor () {
15 | super()
16 |
17 | this.state = {
18 | currentIndex: INITIAL_INDEX,
19 | isEmpty: false,
20 | isLoading: true,
21 | storyQueue: []
22 | }
23 |
24 | this._actionStory = this._actionStory.bind(this)
25 | this._store = this._store.bind(this)
26 | }
27 |
28 | componentWillMount () {
29 | const { url, relations } = this.props
30 |
31 | Downloader.create(url, this._store, relations.Root)
32 | }
33 |
34 | componentDidMount () {
35 | const channel = postal.channel()
36 | this.subscription = channel.subscribe('action_triggered', this._actionStory)
37 | }
38 |
39 | componentWillUnmount () {
40 | this.subscription.unsubscribe()
41 | }
42 |
43 | _actionStory ({ element, name }) {
44 | const { identifier } = this.props
45 | if (element !== identifier) return
46 |
47 | switch (name) {
48 | case 'next':
49 | this._next()
50 | break
51 |
52 | case 'prev':
53 | this._prev()
54 | break
55 |
56 | case 'open':
57 | this._openCurrent()
58 | break
59 | }
60 | }
61 |
62 | _store (stories) {
63 | const { identifier, relations } = this.props
64 | const readStories = Storage.retrieve(identifier)
65 | const filteredStories = stories.filter(story =>
66 | readStories.indexOf(jmespath.search(story, relations.ElementKey)) === -1
67 | )
68 |
69 | this.setState({
70 | currentIndex: 0,
71 | isEmpty: filteredStories.length === 0,
72 | isLoading: false,
73 | storyQueue: filteredStories
74 | })
75 | }
76 |
77 | _markAsViewed (story) {
78 | const { identifier, relations } = this.props
79 |
80 | if (story) {
81 | Storage.store(identifier, jmespath.search(story, relations.ElementKey))
82 | }
83 | }
84 |
85 | _next () {
86 | const { currentIndex, storyQueue } = this.state
87 | const index = currentIndex + 1
88 | const validIndex = index <= (storyQueue.length - 1)
89 |
90 | if (validIndex) {
91 | this.setState({ currentIndex: index })
92 | }
93 | }
94 |
95 | _prev () {
96 | const { currentIndex } = this.state
97 | const index = currentIndex - 1
98 | const validIndex = index >= 0
99 |
100 | if (validIndex) {
101 | this.setState({ currentIndex: index })
102 | }
103 | }
104 |
105 | _openCurrent () {
106 | const { relations } = this.props
107 | const { currentIndex, storyQueue } = this.state
108 | const currentStory = storyQueue[currentIndex]
109 | const url = jmespath.search(currentStory, relations.Link)
110 |
111 | this._open(url)
112 | }
113 |
114 | _open (url) {
115 | window.open(url)
116 | }
117 |
118 | render () {
119 | const { currentIndex, isEmpty, isLoading, storyQueue } = this.state
120 | const { identifier, primaryColor, relations, secondaryColor, title, type, } = this.props
121 |
122 | const currentStory = storyQueue && storyQueue[currentIndex]
123 | this._markAsViewed(currentStory)
124 |
125 | return (
126 |
127 |
137 |
138 | )
139 | }
140 | }
141 |
142 | const { string, object } = PropTypes
143 | Viewer.propTypes = {
144 | identifier: string.isRequired,
145 | primaryColor: string.isRequired,
146 | relations: object.isRequired,
147 | secondaryColor: string.isRequired,
148 | title: string.isRequired,
149 | type: string.isRequired,
150 | url: string.isRequired
151 | }
152 |
153 | export default Viewer
154 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | ## Minimal Viewer
4 |
5 | ### How we got here?
6 |
7 | I wanted to develop a simple viewer for HackerNews because when entering the webpage I was annoyed by having to deal with a misleading interface with tons of information which I did not want to see. I was also tired of entering 10 times per day and having to search myself the latest news in the frontpage.
8 |
9 | So I developed [this](http://www.minimalviewer.com/viewers/hackernews). After that, I realized that I was annoyed with other websites too, and so I created the same kind of viewer for those. Then I realized it could be a component.
10 |
11 | And that is how we got here.
12 |
13 | ### Features
14 |
15 | You can navigate through the items with:
16 |
17 | - Left arrow key: Go to previous item
18 | - Right arrow key: Go to next item
19 | - Up arrow key: Go to previous viewer
20 | - Down arrow key: Go to next viewer
21 | - Enter: Open the current item
22 | - Space bar: Open the current item
23 |
24 | ### Usage
25 |
26 | You can see an example of HackerNewsViewer [here](https://github.com/MiguelBel/HackerNewsViewer).
27 |
28 | The JavaScript component is used as below:
29 |
30 | ```
31 | MinimalViewer.initialize(
32 | '#root',
33 | [
34 | {
35 | title: 'HackerNews',
36 | identifier: 'hackernews',
37 | url: 'http://localhost:9400',
38 | relations: {
39 | ElementKey: 'id',
40 | Title: 'title',
41 | Subtitle: 'domain',
42 | Link: 'url',
43 | Root: 'headlines'
44 | },
45 | primary_color: 'black',
46 | secondary_color: 'orange',
47 | type: 'headline'
48 | }
49 | ]
50 | )
51 | ```
52 |
53 | There are two arguments:
54 |
55 | - Selector for the root `div`
56 | - The viewers. It should be an array of hashes. One or more viewers can be set
57 |
58 | A viewer is composed of:
59 |
60 | - **title:** The title which will be shown at the upper left part.
61 | - **identifier:** The identifier of the viewer. It's used to identify the internal process.
62 | - **url:** The URL of the API. It's the URL from which the component will download the data.
63 | - **relations:** The mapping of the downloaded JSON (the JSON must be a plain array with or without a root).
64 | - It should carry:
65 | - __Title:__ Title of the story.
66 | - __Subtitle:__ Subtitle of the story.
67 | - __Link:__ Link for the story.
68 | - __ElementKey:__ (optional) The element on which basis you don't want to show the item again. Can be one of the prior or a different one also included in the mapping.
69 | - __Root:__ (optional) If the json have a root you can configure it here.
70 |
71 | The relations accept complex queries as in [JMESPath](https://github.com/jmespath/jmespath.js), i.e:
72 |
73 | ```
74 | relations: {
75 | ElementKey: 'data.id',
76 | Title: 'data.title',
77 | Subtitle: 'data.domain',
78 | Link: 'data.url',
79 | Root: 'wadus.another_key'
80 | },
81 | ```
82 |
83 | - **primary_color:** The primary color of the viewer.
84 | - **secondary_color:** The secondary color of the viewer.
85 | - **type:** The template choosen (can be headline or story).
86 |
87 | Under the `/dist` folder you can see a simple example of how to use the component to build a minimal viewer.
88 |
89 | ### Development
90 |
91 | The development environment uses Docker and docker-compose. You can start it up with:
92 |
93 | ```
94 | $ docker-compose up
95 | ```
96 |
97 | You can run the functional test suite with this command:
98 |
99 | ```
100 | $ docker-compose exec functional_test_suite npm test
101 | ```
102 |
103 | The ports are mapped this way:
104 |
105 | - 9300 - If you go to `/` you can see an example of how the component is used. Check out the `index.html`file to see how it works. If you go to `/test` you can see how the component is used in the tests. It's mainly the same component but with different usage.
106 | - 9400 - An stub for the API, it returns a fixture for the example
107 |
108 | ### Building
109 |
110 | The app is intended to be served with statics. If you want to serve the web through statics then the only files which you need are located under the `/dist` folder:
111 |
112 | - `index.html` | Is an example of how to use the next two files
113 | - `minimal_viewer-VERSION.css`
114 | - `minimal_viewer-VERSION.js`
115 |
116 | In order to build the `dist` directory you can execute:
117 |
118 | ```
119 | $ make create_version
120 | ```
121 |
122 | ### Contributors
123 |
124 | 1. Add your feature and do not forget the tests
125 | 2. Pull request
126 |
--------------------------------------------------------------------------------