├── .firebaserc ├── public ├── cycle.png ├── favicon.ico ├── images │ ├── apple-icon.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ └── apple-icon-precomposed.png └── manifest.json ├── screens ├── cycle-hn1.png ├── cycle-hn2.png └── lighthouse.png ├── database.rules.json ├── custom-typings.d.ts ├── src ├── pages │ ├── partials │ │ └── _pulse.tsx │ ├── factory.ts │ ├── FeedView │ │ ├── view.tsx │ │ ├── index.tsx │ │ └── reducer.ts │ ├── types.ts │ └── FeedsList │ │ ├── reducer.ts │ │ ├── index.tsx │ │ └── view.tsx ├── components │ ├── CommentCollection.tsx │ ├── FeedCollection.tsx │ ├── CommentAtom.tsx │ └── FeedAtom.tsx ├── interfaces.ts ├── routes.ts ├── index.ts ├── app.tsx └── css │ └── styles.scss ├── tsconfig.json ├── webpack.config.js ├── .gitignore ├── firebase.json ├── LICENSE ├── README.md ├── tslint.json ├── configs ├── webpack.config.test.js └── webpack.config.js ├── test └── app.test.ts ├── index.ejs └── package.json /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "cyclejs-hn" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/cycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/cycle.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /screens/cycle-hn1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/screens/cycle-hn1.png -------------------------------------------------------------------------------- /screens/cycle-hn2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/screens/cycle-hn2.png -------------------------------------------------------------------------------- /screens/lighthouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/screens/lighthouse.png -------------------------------------------------------------------------------- /public/images/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon.png -------------------------------------------------------------------------------- /public/images/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-72x72.png -------------------------------------------------------------------------------- /public/images/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-96x96.png -------------------------------------------------------------------------------- /public/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/favicon-96x96.png -------------------------------------------------------------------------------- /public/images/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-128x128.png -------------------------------------------------------------------------------- /public/images/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-144x144.png -------------------------------------------------------------------------------- /public/images/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-152x152.png -------------------------------------------------------------------------------- /public/images/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-192x192.png -------------------------------------------------------------------------------- /public/images/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-384x384.png -------------------------------------------------------------------------------- /public/images/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/icon-512x512.png -------------------------------------------------------------------------------- /public/images/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/images/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/images/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/images/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/images/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/images/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/images/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/images/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/ms-icon-310x310.png -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | ".read": "auth != null", 4 | ".write": "auth != null" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/images/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/android-icon-36x36.png -------------------------------------------------------------------------------- /public/images/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/android-icon-48x48.png -------------------------------------------------------------------------------- /public/images/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/android-icon-72x72.png -------------------------------------------------------------------------------- /public/images/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/android-icon-96x96.png -------------------------------------------------------------------------------- /public/images/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/images/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/images/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/images/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/images/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/images/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/android-icon-144x144.png -------------------------------------------------------------------------------- /public/images/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/android-icon-192x192.png -------------------------------------------------------------------------------- /public/images/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usm4n/cycle-hn/HEAD/public/images/apple-icon-precomposed.png -------------------------------------------------------------------------------- /custom-typings.d.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line 2 | /// 3 | declare module 'cycle-restart'; 4 | 5 | declare var Snabbdom: any; //Automaticly imported into every file 6 | -------------------------------------------------------------------------------- /src/pages/partials/_pulse.tsx: -------------------------------------------------------------------------------- 1 | import { VNode } from '@cycle/dom'; 2 | 3 | export function pulse(): VNode { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 | ); 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "module": "ES6", 5 | "moduleResolution": "node", 6 | "target": "ES5", 7 | "sourceMap": true, 8 | "jsx": "react", 9 | "jsxFactory": "Snabbdom.createElement", 10 | "noImplicitReturns": true, 11 | "strict": true, 12 | "strictFunctionTypes": false, 13 | "allowJs": true, 14 | "lib": ["es6", "es5", "dom"] 15 | }, 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const appPath = (...names) => path.join(process.cwd(), ...names); 4 | 5 | //This will be merged with the config from the flavor 6 | module.exports = { 7 | entry: { 8 | main: [appPath('src', 'index.ts'), appPath('src', 'css', 'styles.scss')] 9 | }, 10 | output: { 11 | filename: 'bundle.[hash:8].js', 12 | publicPath: '/', 13 | path: appPath('build') 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/CommentCollection.tsx: -------------------------------------------------------------------------------- 1 | import xs, { Stream } from 'xstream'; 2 | import { StateSource, makeCollection } from 'cycle-onionify'; 3 | import { Component, Sources, Sinks } from '../interfaces'; 4 | import { CommentAtom, State as CommentState } from './CommentAtom'; 5 | 6 | export const CommentCollection: Component = makeCollection({ 7 | item: CommentAtom, 8 | itemKey: state => String(state.id), 9 | itemScope: key => key, 10 | collectSinks: instances => ({ 11 | DOM: instances.pickCombine('DOM') 12 | }) 13 | }); 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore when using Git 2 | # http://git-scm.com/docs/gitignore 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | *.log 7 | *.tmp 8 | *.tmp.* 9 | log.txt 10 | *.sublime-project 11 | *.sublime-workspace 12 | .vscode/ 13 | npm-debug.log* 14 | 15 | .idea/ 16 | .sass-cache/ 17 | .tmp/ 18 | .versions/ 19 | coverage/ 20 | dist/ 21 | build/ 22 | node_modules/ 23 | tmp/ 24 | temp/ 25 | hooks/ 26 | platforms/ 27 | plugins/ 28 | plugins/android.json 29 | plugins/ios.json 30 | www/ 31 | $RECYCLE.BIN/ 32 | 33 | .DS_Store 34 | Thumbs.db 35 | UserInterfaceState.xcuserstate 36 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "hosting": { 6 | "public": "build", 7 | "ignore": [ 8 | "firebase.json", 9 | "**/.*", 10 | "**/node_modules/**" 11 | ], 12 | "rewrites": [ 13 | { 14 | "source": "**", 15 | "destination": "/index.html" 16 | } 17 | ], 18 | "headers": [{ 19 | "source": "**/*.@(jpg|jpeg|gif|png|js|css|json|svg|ico|scss|html)", 20 | "headers": [{ 21 | "key": "Cache-Control", 22 | "value": "max-age=31536000" 23 | }] 24 | }] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/FeedCollection.tsx: -------------------------------------------------------------------------------- 1 | import xs, { Stream } from 'xstream'; 2 | import { StateSource, makeCollection } from 'cycle-onionify'; 3 | import { Component, Sources, Sinks } from '../interfaces'; 4 | import { FeedAtom, State as FeedState } from './FeedAtom'; 5 | 6 | export const FeedsCollection: Component = makeCollection({ 7 | item: FeedAtom, 8 | itemKey: state => String(state.id), 9 | itemScope: key => key, 10 | collectSinks: instances => ({ 11 | DOM: instances.pickCombine('DOM').map(itemNodes => 12 | itemNodes.map(item => ({...item, sel: 'li'}) 13 | )) 14 | }) 15 | }); 16 | -------------------------------------------------------------------------------- /src/pages/factory.ts: -------------------------------------------------------------------------------- 1 | import xs from 'xstream'; 2 | import FeedVeiw from './FeedView'; 3 | import FeedView from './FeedView'; 4 | import FeedsList from './FeedsList'; 5 | import { Component } from '../interfaces'; 6 | import { PageSinks, PageSources, PageParams } from './types'; 7 | 8 | export function createPage(page: string, params: PageParams): Component { 9 | const params$ = xs.of(params); 10 | 11 | return (sources: PageSources): PageSinks => { 12 | const pageSources: PageSources = {params$, ...sources}; 13 | 14 | if (page === 'atom') { 15 | return FeedView(pageSources); 16 | } else { 17 | return FeedsList(pageSources); 18 | } 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from 'xstream'; 2 | import { TimeSource } from '@cycle/time'; 3 | import { VNode, DOMSource } from '@cycle/dom'; 4 | import { HTTPSource, RequestOptions } from '@cycle/http'; 5 | import { Location } from 'history'; 6 | import { HistoryInput, HistoryDriver } from '@cycle/history'; 7 | 8 | export type Sources = { 9 | DOM: DOMSource; 10 | HTTP: HTTPSource; 11 | Time: TimeSource; 12 | History: Stream, 13 | }; 14 | 15 | export type RootSinks = { 16 | DOM: Stream; 17 | HTTP: Stream; 18 | History: Stream 19 | }; 20 | 21 | export type Sinks = Partial; 22 | export type Component = (s: Sources) => Sinks; 23 | -------------------------------------------------------------------------------- /src/pages/FeedView/view.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PageState, 3 | FeedViewState as AtomState 4 | } from '../types'; 5 | import { VNode } from '@cycle/dom'; 6 | import xs, { Stream } from 'xstream'; 7 | import { pulse } from '../partials/_pulse'; 8 | 9 | function content(feedDom: VNode, commentsDom: VNode): VNode { 10 | return (
11 | {feedDom} 12 |
13 |
    14 | {commentsDom} 15 |
16 |
17 |
); 18 | } 19 | export function view(state$: Stream, feedDom$: Stream, commentsDom$: Stream): Stream { 20 | return xs.combine(state$, feedDom$, commentsDom$) 21 | .map(([state, feed, comments]) => { 22 | if (!state.isLoading) { 23 | return content(feed, comments); 24 | } else { 25 | return pulse(); 26 | } 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/types.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from 'xstream'; 2 | import { StateSource } from 'cycle-onionify'; 3 | import { Sources, Sinks } from '../interfaces'; 4 | import { State as FeedState } from '../components/FeedAtom'; 5 | import { State as CommentState } from '../components/CommentAtom'; 6 | 7 | export interface PageParams { 8 | id?: string; 9 | page?: string; 10 | [param: string]: any; 11 | } 12 | 13 | export interface PulseLoader { 14 | show: boolean; 15 | } 16 | 17 | interface PageBase { 18 | meta?: PageParams; 19 | isLoading: boolean; 20 | // pulse: PulseLoader; 21 | } 22 | 23 | export interface FeedViewState extends PageBase { 24 | feed: FeedState; 25 | comments: Array; 26 | } 27 | 28 | export interface FeedsListState extends PageBase { 29 | feeds: Array; 30 | } 31 | 32 | export type PageState = FeedsListState | FeedViewState; 33 | 34 | export type PageSinks = Sinks & { onion: Stream }; 35 | export type PageReducer = (prev?: PageState) => PageState | undefined; 36 | export type PageSources = Sources & { onion: StateSource, params$: Stream}; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Usman Riaz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pages/FeedsList/reducer.ts: -------------------------------------------------------------------------------- 1 | import isolate from '@cycle/isolate'; 2 | import xs, { Stream } from 'xstream'; 3 | import { 4 | PageState, 5 | PageSources as Sources, 6 | PageReducer as Reducer 7 | } from '../types'; 8 | import { State as FeedState } from '../../components/FeedAtom'; 9 | 10 | const defaultState: PageState = { 11 | isLoading: true, 12 | meta: { 13 | max: 0, 14 | page: '0', 15 | type: 'news' 16 | }, 17 | feeds: [] as Array 18 | }; 19 | 20 | export function makeReducer$(sources: Sources): Stream { 21 | const params$ = sources.params$; 22 | const http$ = sources.HTTP.select('feeds').flatten() ; 23 | 24 | // reset any residual state 25 | const initReducer$ = xs.of(() => defaultState); 26 | 27 | const pageReducer$ = xs.combine(http$, params$) 28 | .map(([res, params]): any => ({feeds: res.body, params})) 29 | .map(pageData => function(state: PageState): PageState { 30 | return { 31 | ...state, 32 | isLoading: false, 33 | meta: pageData.params, 34 | feeds: pageData.feeds 35 | }; 36 | } as Reducer); 37 | 38 | return xs.merge(initReducer$, pageReducer$); 39 | } 40 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CycleJS Hackernews", 3 | "short_name": "Cycle HN", 4 | "theme_color": "#24242d", 5 | "background_color": "#f3f6fb", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "Scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "images/icon-72x72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "images/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "images/icon-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "images/icon-144x144.png", 28 | "sizes": "144x144", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "images/icon-152x152.png", 33 | "sizes": "152x152", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "images/icon-192x192.png", 38 | "sizes": "192x192", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "images/icon-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "images/icon-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | } 51 | ], 52 | "splash_pages": null 53 | } -------------------------------------------------------------------------------- /src/pages/FeedsList/index.tsx: -------------------------------------------------------------------------------- 1 | import { view } from './view'; 2 | import { VNode } from '@cycle/dom'; 3 | import { API_URL } from '../../app'; 4 | import isolate from '@cycle/isolate'; 5 | import xs, { Stream } from 'xstream'; 6 | import { 7 | PageSinks as Sinks, 8 | PageSources as Sources, 9 | PageReducer as Reducer 10 | } from '../types'; 11 | import { makeReducer$ } from './reducer'; 12 | import { HTTPSource, RequestOptions } from '@cycle/http'; 13 | import { FeedsCollection } from '../../components/FeedCollection'; 14 | 15 | function requestMapper({page, type}: {page: string, type: string}): RequestOptions { 16 | return { 17 | method: 'GET', 18 | category: 'feeds', 19 | query: {page}, 20 | url: API_URL + `/${type}.json` 21 | }; 22 | } 23 | 24 | export default function FeedsList(sources: Sources): Sinks { 25 | const state$ = sources.onion.state$; 26 | 27 | const request$ = sources.params$.map(requestMapper); 28 | 29 | const reducers$: Stream = makeReducer$(sources); 30 | const feedsCollection = isolate(FeedsCollection, 'feeds')(sources); 31 | 32 | const feedsDom$ = feedsCollection.DOM; 33 | const pageDom$: Stream = view(state$, feedsDom$); 34 | 35 | const sinks = { 36 | DOM: pageDom$, 37 | HTTP: request$, 38 | onion: reducers$ 39 | }; 40 | 41 | return sinks; 42 | } 43 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Component } from './interfaces'; 2 | import { PageParams } from './pages/types'; 3 | import { createPage } from './pages/factory'; 4 | 5 | export interface RouteDefinition { 6 | [path: string]: RouteDefinition | any; 7 | } 8 | 9 | export interface MatchedRoute { 10 | path: string | null; 11 | value: Component | any; 12 | } 13 | 14 | export const Routes: RouteDefinition = { 15 | '/': createPage('list', {max: 10, type: 'news', page: '1'}), 16 | '/news': createPage('list', {max: 10, type: 'news', page: '1'}), 17 | '/news/:page': (page: string) => createPage('list', {max: 10, type: 'news', page}), 18 | '/newest': createPage('list', {max: 12, type: 'newest', page: '1'}), 19 | '/newest/:page': (page: string) => createPage('list', {max: 12, type: 'newest', page}), 20 | '/show': createPage('list', {max: 2, type: 'show', page: '1'}), 21 | '/show/:page': (page: string) => createPage('list', {max: 2, type: 'show', page}), 22 | '/ask': createPage('list', {max: 3, type: 'ask', page: '1'}), 23 | '/ask/:page': (page: string) => createPage('list', {max: 3, type: 'ask', page}), 24 | '/jobs': createPage('list', {max: 1, type: 'show', page: '1'}), 25 | '/jobs/:page': (page: string) => createPage('list', {max: 1, type: 'jobs', page}), 26 | '/atom/:id': (id: string) => createPage('atom', {id, type: 'item'}), 27 | '*': createPage('list', {max: 10, type: 'news', page: '1'}) 28 | }; 29 | -------------------------------------------------------------------------------- /src/pages/FeedView/index.tsx: -------------------------------------------------------------------------------- 1 | import { view } from './view'; 2 | import { VNode } from '@cycle/dom'; 3 | import { API_URL } from '../../app'; 4 | import isolate from '@cycle/isolate'; 5 | import xs, { Stream } from 'xstream'; 6 | import { 7 | PageSinks as Sinks, 8 | PageSources as Sources, 9 | PageReducer as Reducer 10 | } from '../types'; 11 | import { makeReducer$ } from './reducer'; 12 | import { FeedAtom } from '../../components/FeedAtom'; 13 | import { HTTPSource, RequestOptions } from '@cycle/http'; 14 | import { CommentCollection } from '../../components/CommentCollection'; 15 | 16 | function requestMapper({id, type}: {id: string, type: string}): RequestOptions { 17 | return { 18 | method: 'GET', 19 | category: 'atom', 20 | url: API_URL + `/${type}/${id}.json` 21 | }; 22 | } 23 | 24 | export default function FeedsView(sources: Sources): Sinks { 25 | const state$ = sources.onion.state$; 26 | 27 | const request$ = sources.params$.map(requestMapper); 28 | 29 | const reducers$: Stream = makeReducer$(sources); 30 | const feedAtom = isolate(FeedAtom, 'feed')(sources); 31 | const commentCollection = isolate(CommentCollection, 'comments')(sources); 32 | 33 | const pageDom$: Stream = view(state$, feedAtom.DOM, commentCollection.DOM); 34 | 35 | const sinks = { 36 | DOM: pageDom$, 37 | HTTP: request$, 38 | onion: reducers$ 39 | }; 40 | 41 | return sinks; 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/FeedView/reducer.ts: -------------------------------------------------------------------------------- 1 | import isolate from '@cycle/isolate'; 2 | import xs, { Stream } from 'xstream'; 3 | import { 4 | PageState, 5 | PageSources as Sources, 6 | PageReducer as Reducer 7 | } from '../types'; 8 | import { State as FeedState } from '../../components/FeedAtom'; 9 | import { State as CommentState } from '../../components/CommentAtom'; 10 | 11 | const defaultState: PageState = { 12 | isLoading: true, 13 | feed: {} as FeedState, 14 | comments: {} as Array 15 | }; 16 | 17 | function extractFeed(data: any): FeedState { 18 | const keys: Array = Object.keys(data); 19 | 20 | return keys.reduce((feed: FeedState, key: string) => { 21 | if (key !== 'comments') { 22 | feed[key] = data[key]; 23 | } 24 | 25 | return feed; 26 | }, {} as FeedState); 27 | } 28 | 29 | export function makeReducer$(sources: Sources): Stream { 30 | const http$ = sources.HTTP.select('atom').flatten() ; 31 | 32 | const initReducer$ = xs.of(() => defaultState); 33 | 34 | const pageReducer$ = http$.map((res: any) => res.body) 35 | .map(pageData => function(state: PageState): PageState { 36 | return { 37 | ...state, 38 | isLoading: false, 39 | feed: extractFeed(pageData), 40 | comments: pageData.comments 41 | }; 42 | } as Reducer); 43 | 44 | return xs.merge(initReducer$, pageReducer$); 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # [cycle-hn](https://github.com/usm4n/cycle-hn) 4 | 5 | A [CycleJS](https://github.com/cyclejs/cyclejs) & 6 | [cycle-onionify](https://github.com/staltz/cycle-onionify)-powered implementation of 7 | [Hacker News](https://news.ycombinator.com) using its 8 | [HNPWA API](https://github.com/tastejs/hacker-news-pwas/blob/master/docs/api.md). 9 | 10 | ## Screenshot 11 | 12 | ![cycle-hn screenshot 1](https://github.com/usm4n/cycle-hn/blob/master/screens/cycle-hn1.png) 13 | 14 | ![cycle-hn screenshot 2](https://github.com/usm4n/cycle-hn/blob/master/screens/cycle-hn2.png) 15 | 16 | ### Lighthouse stats 17 | ![cycle-hn screenshot 2](https://github.com/usm4n/cycle-hn/blob/master/screens/lighthouse.png) 18 | 19 | Live version: 20 | 21 | [@Now](https://build-mnrpnfoxlt.now.sh) - preferred 22 | 23 | [@Firebaseapp](https://cyclejs-hn.firebaseapp.com) 24 | 25 | [Feature requests are welcome!](https://github.com/usm4n/cycle-hn/issues/new) 26 | 27 | ## Building 28 | 29 | Install dependencies: 30 | 31 | ``` 32 | npm install 33 | ``` 34 | 35 | ### npm scripts 36 | 37 | * `npm start` - start development server 38 | * `npm run build` - build into the `build/` directory 39 | 40 | ## Acknowledgement 41 | [Jan van Brügge](https://github.com/jvanbruegge) for awesome [one-fits-all](https://github.com/cyclejs-community/one-fits-all) flavor for [cycle-scripts](https://github.com/cyclejs-community/create-cycle-app) 42 | 43 | 44 | ### License 45 | Cycle-hn is a free software realeased under the terms of MIT license. 46 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "member-access": true, 4 | "no-reference": true, 5 | "typedef": [ 6 | true, 7 | "call-signature", 8 | "call-signature", 9 | "parameter", 10 | "property-declaration", 11 | "member-variable-declaration" 12 | ], 13 | "typedef-whitespace": [ 14 | true, 15 | { 16 | "call-signature": "nospace", 17 | "index-signature": "nospace", 18 | "parameter": "nospace", 19 | "property-declaration": "nospace", 20 | "variable-declaration": "nospace" 21 | }, 22 | { 23 | "call-signature": "onespace", 24 | "index-signature": "onespace", 25 | "parameter": "onespace", 26 | "property-declaration": "onespace", 27 | "variable-declaration": "onespace" 28 | } 29 | ], 30 | "curly": true, 31 | "no-duplicate-variable": true, 32 | "no-empty": true, 33 | "no-eval": true, 34 | "no-null-keyword": true, 35 | "no-shadowed-variable": true, 36 | "no-use-before-declare": true, 37 | "no-var-keyword": true, 38 | "switch-default": true, 39 | "triple-equals": true, 40 | "use-isnan": true, 41 | "prefer-const": true, 42 | "trailing-comma": [true, { "multiline": "never", "singleline": "never" }], 43 | "class-name": true, 44 | "no-angle-bracket-type-assertion": true, 45 | "no-consecutive-blank-lines": true, 46 | "quotemark": [true, "single", "jsx-double"], 47 | "semicolon": [true, "always"], 48 | "space-before-function-paren": [true, "never"], 49 | "whitespace": [ 50 | true, 51 | "check-decl", 52 | "check-operator", 53 | "check-module", 54 | "check-type" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import xs from 'xstream'; 2 | import isolate from '@cycle/isolate'; 3 | import onionify from 'cycle-onionify'; 4 | import { setup, run } from '@cycle/run'; 5 | import { timeDriver } from '@cycle/time'; 6 | import { makeDOMDriver } from '@cycle/dom'; 7 | import { makeHTTPDriver } from '@cycle/http'; 8 | import { restartable, rerunner } from 'cycle-restart'; 9 | import { makeHistoryDriver, captureClicks } from '@cycle/history'; 10 | 11 | import { App } from './app'; 12 | import { Component, Sources, RootSinks } from './interfaces'; 13 | 14 | const main: Component = onionify(App); 15 | 16 | let drivers: any, driverFn: any; 17 | /// #if PRODUCTION 18 | drivers = { 19 | DOM: makeDOMDriver('#app'), 20 | HTTP: makeHTTPDriver(), 21 | Time: timeDriver, 22 | History: captureClicks(makeHistoryDriver()) 23 | }; 24 | /// #else 25 | driverFn = () => ({ 26 | DOM: restartable(makeDOMDriver('#app'), { 27 | pauseSinksWhileReplaying: false 28 | }), 29 | HTTP: restartable(makeHTTPDriver()), 30 | Time: timeDriver, 31 | History: captureClicks(makeHistoryDriver()) 32 | }); 33 | /// #endif 34 | export const driverNames: string[] = Object.keys(drivers || driverFn()); 35 | 36 | /// #if PRODUCTION 37 | run(main as any, drivers); 38 | // register service worker 39 | if ('serviceWorker' in navigator) { 40 | navigator.serviceWorker 41 | .register('/service-worker.js') 42 | .then(registration => console.log('SW registration successful with scope: ', registration.scope)); 43 | } 44 | /// #else 45 | const rerun = rerunner(setup, driverFn, isolate); 46 | rerun(main as any); 47 | 48 | if (module.hot) { 49 | module.hot.accept('./app', () => { 50 | const newApp = require('./app').App; 51 | 52 | rerun(onionify(newApp)); 53 | }); 54 | } 55 | /// #endif 56 | -------------------------------------------------------------------------------- /configs/webpack.config.test.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.config.js'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | 4 | module.exports = Object.assign({}, config, { 5 | target: 'node', 6 | devtool: 'inline-source-map', 7 | externals: [nodeExternals()], 8 | plugins: config.plugins.filter(p => !(p.options && p.options.template)), //Exclude HtmlWebpackPlugin 9 | module: Object.assign({}, config.module, { 10 | loaders: config.module.loaders.map(l => { 11 | if ( 12 | l.loaders && 13 | l.loaders.reduce( 14 | (acc, curr) => 15 | acc || /awesome-typescript-loader.*/.test(curr), 16 | false 17 | ) 18 | ) { 19 | return Object.assign({}, l, { 20 | loaders: l.loaders 21 | .filter(e => !/awesome-typescript-loader.*/.test(e)) 22 | .concat([ 23 | 'awesome-typescript-loader?' + 24 | JSON.stringify({ 25 | useBabel: true, 26 | babelOptions: { 27 | env: { 28 | test: { 29 | plugins: ['istanbul'] 30 | } 31 | } 32 | }, 33 | useCache: true, 34 | cacheDirectory: 35 | 'node_modules/.cache/at-loader' 36 | }) 37 | ]) 38 | }); 39 | } 40 | return l; 41 | }) 42 | }) 43 | }); 44 | -------------------------------------------------------------------------------- /src/pages/FeedsList/view.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PageState, 3 | PageParams 4 | } from '../types'; 5 | import { VNode } from '@cycle/dom'; 6 | import xs, { Stream } from 'xstream'; 7 | import { pulse } from '../partials/_pulse'; 8 | 9 | function prevLink(pageData: PageParams): VNode { 10 | return +pageData.page! > 1 11 | ? < prev 12 | : < prev ; 13 | } 14 | 15 | function nextLink(pageData: PageParams): VNode { 16 | return +pageData.page! < pageData.max 17 | ? next > 18 | : next >; 19 | } 20 | 21 | function currentPosition(pageData: PageParams): VNode { 22 | return {`${pageData.page} / ${pageData.max}`}; 23 | } 24 | 25 | function pager(pageData: PageParams): VNode { 26 | return ( 27 |
28 | {prevLink(pageData)} 29 | {currentPosition(pageData)} 30 | {nextLink(pageData)} 31 |
32 | ); 33 | } 34 | 35 | function startOrder(pageData: PageParams): number { 36 | return ((+pageData.page! - 1) * 30) + 1; 37 | } 38 | 39 | function content(state: PageState, feedsDom: VNode): VNode { 40 | return ( 41 |
42 |
    {feedsDom}
43 | {/* this needs to be looked into */} 44 | {state.meta && pager(state.meta)} 45 |
46 | ); 47 | } 48 | 49 | export function view(state$: Stream, feedsDom$: Stream): Stream { 50 | return xs.combine(state$, feedsDom$) 51 | .map(([state, feedsDom]) => { 52 | if (!state.isLoading) { 53 | return content(state, feedsDom); 54 | } else { 55 | return pulse(); 56 | } 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /test/app.test.ts: -------------------------------------------------------------------------------- 1 | import { forall, assert, nat, Options } from 'jsverify'; 2 | import { diagramArbitrary, withTime } from 'cyclejs-test-helpers'; 3 | const htmlLooksLike = require('html-looks-like'); 4 | const toHtml = require('snabbdom-to-html'); //snabbdom-to-html's typings are broken 5 | 6 | import xs, { Stream } from 'xstream'; 7 | import { mockDOMSource, VNode } from '@cycle/dom'; 8 | import { mockTimeSource } from '@cycle/time'; 9 | import onionify from 'cycle-onionify'; 10 | 11 | import { App } from '../src/app'; 12 | 13 | const testOptions: Options = { 14 | tests: 10, 15 | size: 200 16 | }; 17 | 18 | describe('app tests', () => { 19 | const expectedHTML = (count: number) => ` 20 |
21 |

My Awesome Cycle.js app

22 | Counter: ${count} 23 | 24 | 25 |
26 | `; 27 | 28 | it('should interact correctly', () => { 29 | const property = forall( 30 | diagramArbitrary, 31 | diagramArbitrary, 32 | (addDiagram, subtractDiagram) => 33 | withTime(Time => { 34 | const add$ = Time.diagram(addDiagram); 35 | const subtract$ = Time.diagram(subtractDiagram); 36 | 37 | const DOM = mockDOMSource({ 38 | '.add': { click: add$ }, 39 | '.subtract': { click: subtract$ } 40 | }); 41 | 42 | const app = onionify(App)({ DOM } as any); 43 | const html$ = (app.DOM as Stream).map(toHtml); 44 | 45 | const expected$ = xs 46 | .merge(add$.mapTo(+1), subtract$.mapTo(-1)) 47 | .fold((acc, curr) => acc + curr, 0) 48 | .map(expectedHTML); 49 | 50 | Time.assertEqual(html$, expected$, htmlLooksLike); 51 | }) 52 | ); 53 | 54 | return assert(property, testOptions); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Cycle HN 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 |
33 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/components/CommentAtom.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Sinks, 3 | Sources, 4 | Component 5 | } from '../interfaces'; 6 | import { VNode } from '@cycle/dom'; 7 | import isolate from '@cycle/isolate'; 8 | import xs, { Stream } from 'xstream'; 9 | import { StateSource } from 'cycle-onionify'; 10 | import { CommentCollection } from './CommentCollection'; 11 | 12 | export interface State { 13 | id: number; 14 | level: number; 15 | user: string; 16 | time: number; 17 | time_ago: string; 18 | content: string; 19 | comments: Array; 20 | } 21 | 22 | export type Reducer = (prev: State) => State; 23 | export type CommentSinks = Sinks & { onion: Stream }; 24 | export type CommentSources = Sources & { onion: StateSource }; 25 | 26 | function intent(sources: Sources): Stream { 27 | return sources.DOM.select('.comment-hide') 28 | .events('click') 29 | .mapTo(undefined) 30 | .fold((show: boolean) => !show, true); 31 | } 32 | 33 | function view(state$: Stream, commentChildren$: Stream, action$: Stream): Stream { 34 | return xs.combine(state$, commentChildren$, action$) 35 | .map(([comment, children, showComment]) => 36 |
  • 37 |
    38 | {comment.user} 39 | {comment.time_ago} 40 | [{showComment ? '-' : '+'}] 41 |
    42 | {showComment &&
    43 | reply 44 |
    } 45 | {(children && showComment) &&
      46 | {children} 47 |
    } 48 |
  • 49 | ); 50 | } 51 | 52 | export const CommentAtom: Component = function(sources: CommentSources): CommentSinks { 53 | const action$ = intent(sources); 54 | const commentChildren = isolate(CommentCollection, 'comments')(sources); 55 | 56 | const vdom$ = view(sources.onion.state$, commentChildren.DOM, action$); 57 | 58 | const sinks = { 59 | DOM: vdom$, 60 | onion: xs.of((state) => state) 61 | }; 62 | 63 | return sinks; 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/FeedAtom.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Sinks, 3 | Sources, 4 | Component 5 | } from '../interfaces'; 6 | import { VNode } from '@cycle/dom'; 7 | import xs, { Stream } from 'xstream'; 8 | import { StateSource } from 'cycle-onionify'; 9 | 10 | export interface State { 11 | id: number; 12 | title: string; 13 | points: number; 14 | user: string; 15 | time: number; 16 | time_ago: string; 17 | comments_count: number; 18 | content?: string; 19 | type: string; 20 | url: string; 21 | domain: string; 22 | [data: string]: any; 23 | } 24 | 25 | export type Reducer = (prev: State) => State; 26 | export type FeedSinks = Sinks & { onion: Stream }; 27 | export type FeedSources = Sources & { onion: StateSource }; 28 | 29 | function url(feed: State): VNode { 30 | if (feed.url && feed.url.startsWith('http')) { 31 | return {feed.title}; 32 | } else { 33 | return {feed.title}; 34 | } 35 | } 36 | 37 | function domain(feed: State): any { 38 | return feed.domain && ({feed.domain}); 39 | } 40 | 41 | function author(feed: State): any { 42 | return feed.type !== 'job' 43 | && 44 | by 45 | {feed.user} 46 | ; 47 | } 48 | 49 | function points(feed: State): any { 50 | return feed.type !== 'job' && {feed.points} points ; 51 | } 52 | 53 | function commentsLink(feed: State): any { 54 | return feed.type !== 'job' && | {feed.comments_count} comments ; 55 | } 56 | 57 | function view(state$: Stream): Stream { 58 | return state$.map(feed => 59 |
    60 | {url(feed)} 61 | {domain(feed)} 62 |
    63 | {points(feed)} 64 | {author(feed)} 65 | {feed.time_ago} {commentsLink(feed)} 66 |
    67 |
    68 | ); 69 | } 70 | 71 | export const FeedAtom: Component = function(sources: FeedSources): FeedSinks { 72 | const vdom$ = view(sources.onion.state$); 73 | 74 | const sinks = { 75 | DOM: vdom$, 76 | onion: xs.of((state) => state) 77 | }; 78 | 79 | return sinks; 80 | }; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycle-hn", 3 | "author": "Usman Riaz", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "http://github.com/usm4n/cycle-hn.git" 8 | }, 9 | "version": "0.1.0", 10 | "private": true, 11 | "devDependencies": { 12 | "@types/mocha": "^2.2.41", 13 | "@types/history": "^4.6.0", 14 | "@types/webpack-env": "^1.13.0", 15 | "@webpack-blocks/dev-server2": "^0.4.0", 16 | "@webpack-blocks/extract-text2": "^0.4.0", 17 | "@webpack-blocks/postcss": "^0.4.3", 18 | "@webpack-blocks/sass": "^0.4.1", 19 | "@webpack-blocks/tslint": "^0.4.0", 20 | "@webpack-blocks/typescript": "^0.4.1", 21 | "@webpack-blocks/webpack2": "^0.4.0", 22 | "autoprefixer": "^7.1.1", 23 | "babel-core": "^6.25.0", 24 | "babel-plugin-istanbul": "^4.1.4", 25 | "chalk": "^2.0.1", 26 | "clean-webpack-plugin": "^0.1.16", 27 | "copy-webpack-plugin": "^4.0.1", 28 | "cross-env": "^5.0.1", 29 | "cycle-restart": "^0.2.2", 30 | "cyclejs-test-helpers": "^1.3.0", 31 | "html-looks-like": "^1.0.3", 32 | "html-webpack-plugin": "^2.29.0", 33 | "ifdef-loader": "^1.1.1", 34 | "jsverify": "^0.8.2", 35 | "mocha": "^3.4.2", 36 | "mocha-webpack": "^0.7.0", 37 | "node-sass": "^4.5.3", 38 | "nyc": "^11.0.3", 39 | "rimraf": "^2.6.1", 40 | "snabbdom-pragma": "^2.4.0", 41 | "snabbdom-to-html": "^3.2.0", 42 | "typescript": "2.4.x", 43 | "uglifyjs-webpack-plugin": "^1.0.0-beta.3", 44 | "webpack": "^2.6.1", 45 | "webpack-dev-server": "^2.5.0", 46 | "webpack-node-externals": "^1.6.0", 47 | "sw-precache-webpack-plugin": "^0.11.4" 48 | }, 49 | "dependencies": { 50 | "@cycle/dom": "^18.0.0", 51 | "@cycle/history": "^6.4.0", 52 | "@cycle/http": "^14.0.0", 53 | "@cycle/isolate": "^3.0.0", 54 | "@cycle/run": "^3.1.0", 55 | "@cycle/time": "^0.8.0", 56 | "cycle-onionify": "4.0.0", 57 | "cyclejs-utils": "^1.0.4", 58 | "switch-path": "^1.2.0", 59 | "xstream": "^10.9.0" 60 | }, 61 | "scripts": { 62 | "start": "cross-env NODE_ENV=development webpack-dev-server --config configs/webpack.config.js", 63 | "test": "cross-env NODE_ENV=test nyc mocha-webpack --timeout=100000 --colors --webpack-config configs/webpack.config.test.js test/**/*.test.*", 64 | "build": "cross-env NODE_ENV=production webpack --config configs/webpack.config.js", 65 | "clean": "rimraf build .tmp .nyc_output coverage" 66 | }, 67 | "nyc": { 68 | "include": [ 69 | "src" 70 | ], 71 | "reporter": [ 72 | "html", 73 | "text-summary" 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { Location } from 'history'; 2 | import switchPath from 'switch-path'; 3 | import xs, { Stream } from 'xstream'; 4 | import isolate from '@cycle/isolate'; 5 | import { PageState } from './pages/types'; 6 | import FeedsList from './pages/FeedsList'; 7 | import { StateSource } from 'cycle-onionify'; 8 | import { extractSinks } from 'cyclejs-utils'; 9 | import { Sources, Sinks } from './interfaces'; 10 | import { VNode, DOMSource } from '@cycle/dom'; 11 | import { Routes, MatchedRoute } from './routes'; 12 | 13 | export const API_URL = 'https://hnpwa.com/api/v0'; 14 | 15 | export type AppState = { 16 | page: PageState; 17 | }; 18 | 19 | const defaultAppState: AppState = { 20 | page: {} as PageState 21 | }; 22 | 23 | export type Reducer = (prev?: AppState) => AppState | undefined; 24 | export type AppSinks = Sinks & { onion: Stream }; 25 | export type AppSources = Sources & { onion: StateSource}; 26 | 27 | function navigation(pathname: string): VNode { 28 | return ( 29 | 30 | top 31 | new 32 | show 33 | ask 34 | jobs 35 | 36 | ); 37 | } 38 | 39 | function view(history$: Stream, vdom$: Stream): Stream { 40 | return xs.combine(history$, vdom$).map(([{pathname}, vdom]: [{pathname: string}, VNode]) => 41 |
    42 |
    43 |
    44 | 45 | logo 46 | 47 | Cycle HN 48 | {navigation(pathname)} 49 |
    50 |
    51 |
    52 | {vdom} 53 |
    54 |
    55 |

    Fork project at usm4n/cycle-hn

    56 |
    57 |
    58 | ); 59 | } 60 | 61 | function initState(): Stream { 62 | const initReducer$ = xs.of( 63 | prevState => (prevState === undefined ? defaultAppState : prevState) 64 | ); 65 | 66 | return initReducer$; 67 | } 68 | 69 | export function App(sources: AppSources): AppSinks { 70 | const history$: Stream = sources.History; 71 | 72 | const initState$ = initState(); 73 | 74 | const pageSinks$ = history$.map((location: Location): MatchedRoute => { 75 | const {pathname} = location; 76 | 77 | return switchPath(pathname, Routes); 78 | }).map((route: MatchedRoute) => isolate(route.value, 'page')(sources)); 79 | 80 | const pageSinks = extractSinks(pageSinks$, ['DOM', 'HTTP', 'onion']); 81 | 82 | const reducers$ = xs.merge(pageSinks.onion, initState$); 83 | 84 | const vdom$ = view(history$, pageSinks.DOM as Stream); 85 | 86 | return { 87 | DOM: vdom$, 88 | onion: reducers$, 89 | HTTP: pageSinks.HTTP 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /configs/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | createConfig, 3 | defineConstants, 4 | env, 5 | entryPoint, 6 | setOutput, 7 | sourceMaps, 8 | addPlugins 9 | } = require('@webpack-blocks/webpack2'); 10 | const devServer = require('@webpack-blocks/dev-server2'); 11 | const postcss = require('@webpack-blocks/postcss'); 12 | const sass = require('@webpack-blocks/sass'); 13 | const typescript = require('@webpack-blocks/typescript'); 14 | const tslint = require('@webpack-blocks/tslint'); 15 | const extractText = require('@webpack-blocks/extract-text2'); 16 | const autoprefixer = require('autoprefixer'); 17 | const webpack = require('webpack'); 18 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 19 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 20 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 21 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 22 | const SWPreCachePlugin = require('sw-precache-webpack-plugin'); 23 | 24 | const path = require('path'); 25 | const fs = require('fs'); 26 | 27 | const preprocessor = production => ({ 28 | PRODUCTION: production, 29 | DEVELOPMENT: !production 30 | }); 31 | 32 | const ifdef = (opts, block) => context => { 33 | let conf = block(context); 34 | conf.module.loaders[0].loaders.push( 35 | `ifdef-loader?json=${JSON.stringify(opts)}` 36 | ); 37 | return conf; 38 | }; 39 | 40 | const tsIfDef = production => 41 | ifdef( 42 | preprocessor(production), 43 | typescript({ 44 | useCache: true, 45 | cacheDirectory: 'node_modules/.cache/at-loader' 46 | }) 47 | ); 48 | 49 | const appPath = (...names) => path.join(process.cwd(), ...names); 50 | 51 | const customConfig = fs.existsSync(appPath('webpack.config.js')) 52 | ? require(appPath('webpack.config.js')) 53 | : {}; 54 | 55 | if (customConfig === undefined) { 56 | throw new Error( 57 | 'The 3.0 update is a breaking release, you need to upgrade manually. Please refer to https://github.com/cyclejs-community/create-cycle-app-flavors#migrating' 58 | ); 59 | } 60 | 61 | module.exports = createConfig([ 62 | () => customConfig, //Include user config 63 | tslint(), 64 | sass(), 65 | postcss([autoprefixer({ browsers: ['last 2 versions'] })]), 66 | defineConstants({ 67 | 'process.env.NODE_ENV': process.env.NODE_ENV 68 | }), 69 | addPlugins([ 70 | new HtmlWebpackPlugin({ 71 | template: './index.ejs', 72 | inject: true, 73 | favicon: 'public/favicon.ico', 74 | hash: true 75 | }), 76 | new webpack.ProvidePlugin({ 77 | Snabbdom: 'snabbdom-pragma' 78 | }) 79 | ]), 80 | env('development', [ 81 | tsIfDef(false), 82 | devServer(), 83 | sourceMaps(), 84 | addPlugins([new webpack.NamedModulesPlugin()]) 85 | ]), 86 | env('production', [ 87 | tsIfDef(true), 88 | extractText('[name].[chunkhash:8].css', 'text/x-sass'), 89 | addPlugins([ 90 | new CleanWebpackPlugin([appPath('build')], { 91 | root: process.cwd() 92 | }), 93 | new CopyWebpackPlugin([{ from: 'public', to: 'public' }]), 94 | new UglifyJsPlugin(), 95 | new SWPreCachePlugin({ 96 | minify: true, 97 | cacheId: 'cycle-hn', 98 | filename: 'service-worker.js', 99 | navigateFallback: 'index.html', 100 | dontCacheBustUrlsMatching:/\.\w{8}\./, 101 | staticFileGlobsIgnorePatterns: [/\.map$/, /\.json$/], 102 | }) 103 | ]) 104 | ]), 105 | env('test', [tsIfDef(true)]) 106 | ]); 107 | -------------------------------------------------------------------------------- /src/css/styles.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | color: #202020; 4 | font-family : Roboto, "Helvetica Neue", "DejaVu Sans", "Fira Sans", "Droid Sans", sans-serif; 5 | font-size: 14px; 6 | margin: 0; 7 | overflow-y: scroll; 8 | } 9 | .main-wrapper { 10 | background-color: #f3f6fb; 11 | width: 100%; 12 | min-height: 75px; 13 | height: 100%; 14 | } 15 | .header-wrapper { 16 | color: #c6fc93; 17 | background-color: #24242d; 18 | font-size: 16px; 19 | height: 40px; 20 | line-height: 16px; 21 | overflow: hidden; 22 | padding: 4px; 23 | position: relative; 24 | .header-inner { 25 | width: 75%; 26 | margin: 0 auto; 27 | padding: 10px; 28 | vertical-align: middle; 29 | .logo { 30 | border: 1px #c6fc93 solid; 31 | margin-right: 15px; 32 | vertical-align: middle; 33 | width: 24px; 34 | } 35 | a { 36 | margin: 0 5px; 37 | color: #c6fc93; 38 | text-decoration: none; 39 | &.active { 40 | color: #f3f6fb; 41 | } 42 | } 43 | .home { 44 | font-weight: bold; 45 | margin-right: .5em; 46 | @media screen and (max-width: 600px) { 47 | display: none; 48 | } 49 | } 50 | @media screen and (max-width: 600px) { 51 | width: 100%; 52 | } 53 | } 54 | } 55 | .footer { 56 | color: #808080; 57 | background-color: #fff; 58 | margin: 0 auto; 59 | width: 75%; 60 | height: 30px; 61 | overflow: hidden; 62 | padding: 15px 0; 63 | text-align: center; 64 | .github { 65 | color: #606060; 66 | font-weight: bold; 67 | text-decoration: none; 68 | } 69 | @media screen and (max-width: 600px) { 70 | width: 100%; 71 | } 72 | p { margin: 0; } 73 | } 74 | .main-content { 75 | background-color: #fff; 76 | border-bottom: 2px #24242d solid; 77 | width: 75%; 78 | line-height: 1.5em; 79 | min-height: 85vh; 80 | margin: 0 auto; 81 | overflow: hidden; 82 | position: relative; 83 | word-wrap: break-word; 84 | a { 85 | color: #202020; 86 | text-decoration: none; 87 | &:hover { 88 | text-decoration: underline; 89 | } 90 | &:visited { 91 | color: #606060; 92 | } 93 | } 94 | @media screen and (max-width: 600px) { 95 | width: 100%; 96 | margin: 0; 97 | } 98 | } 99 | .feed-list { 100 | margin: 10px 0; 101 | padding-left: 35px; 102 | padding-right: 15px; 103 | } 104 | .feed-view { 105 | margin: 10px 0; 106 | padding: 0 20px; 107 | } 108 | .feed { 109 | color: #808080; 110 | font-size: 12px; 111 | margin: 10px 0; 112 | } 113 | .feed-title { 114 | font-size: 16px; 115 | } 116 | .feed-domain { 117 | margin-left: 10px; 118 | } 119 | .feed-author { 120 | font-weight: bold; 121 | } 122 | .feed-pager { 123 | text-align: center; 124 | padding: 20px 0; 125 | font-weight: bold; 126 | .disabled { 127 | color: #808080; 128 | } 129 | .pager-position { 130 | color: #808080; 131 | margin: 0 10px; 132 | font-weight: normal; 133 | } 134 | } 135 | .comment-list { 136 | list-style: none; 137 | padding: 0; 138 | margin: 20px 0; 139 | .reply { 140 | font-size: 12px; 141 | text-decoration: underline; 142 | } 143 | .comment-list { 144 | margin-left: 20px; 145 | margin-top: 0px; 146 | margin-bottom: 0px; 147 | } 148 | } 149 | .comment { 150 | padding: 8px 0px; 151 | } 152 | .comment-hide { 153 | color: #202002; 154 | cursor: pointer; 155 | font-size: 13.3333px; 156 | } 157 | .comment-header { 158 | font-size: 12px; 159 | color: #808080; 160 | margin-bottom: 8px; 161 | &.active { 162 | background: #c6fc93; 163 | } 164 | } 165 | .comment-author { 166 | color: #606060; 167 | font-weight: bold; 168 | } 169 | .pulse { 170 | position: absolute; 171 | top: 5%; 172 | left: 2%; 173 | .pulse-bar { 174 | background-color: #24242d; 175 | display: inline-block; 176 | width: 4px; 177 | height: 20px; 178 | margin: 0 2px; 179 | border-radius: 4px; 180 | animation: pulse 1s ease-in-out infinite; 181 | &:nth-child(1) { 182 | animation-delay: 0; 183 | } 184 | &:nth-child(2) { 185 | animation-delay: 0.09s; 186 | } 187 | &:nth-child(3) { 188 | animation-delay: .18s; 189 | } 190 | &:nth-child(4) { 191 | animation-delay: .27s; 192 | } 193 | } 194 | } 195 | @keyframes pulse { 196 | 0% { 197 | background-color: #c6fc93; 198 | transform: scale(1); 199 | } 200 | 20% { 201 | transform: scale(1, 2.2); 202 | } 203 | 40% { 204 | transform: scale(1); 205 | } 206 | } --------------------------------------------------------------------------------