aboutsummaryrefslogtreecommitdiff
path: root/src/web/js
diff options
context:
space:
mode:
authorCédric Bonhomme <cedric@cedricbonhomme.org>2016-02-03 07:11:37 +0100
committerCédric Bonhomme <cedric@cedricbonhomme.org>2016-02-03 07:11:37 +0100
commitda929a367c3f1fe5f3546be82e47111c2fa84ad3 (patch)
tree89380f41b802256d8fdbf724e7d9e63b48209b4a /src/web/js
parentMerge pull request #30 from jaesivsm/master (diff)
parentwriting a bit of doc, moving crawler together (diff)
downloadnewspipe-da929a367c3f1fe5f3546be82e47111c2fa84ad3.tar.gz
newspipe-da929a367c3f1fe5f3546be82e47111c2fa84ad3.tar.bz2
newspipe-da929a367c3f1fe5f3546be82e47111c2fa84ad3.zip
Merge pull request #31 from jaesivsm/master
redoing UI
Diffstat (limited to 'src/web/js')
-rw-r--r--src/web/js/actions/MenuActions.js37
-rw-r--r--src/web/js/actions/MiddlePanelActions.js155
-rw-r--r--src/web/js/actions/RightPanelActions.js38
-rw-r--r--src/web/js/app.js18
-rw-r--r--src/web/js/components/MainApp.react.js29
-rw-r--r--src/web/js/components/Menu.react.js286
-rw-r--r--src/web/js/components/MiddlePanel.react.js261
-rw-r--r--src/web/js/components/Navbar.react.js138
-rw-r--r--src/web/js/components/RightPanel.react.js452
-rw-r--r--src/web/js/components/time.react.js15
-rw-r--r--src/web/js/constants/JarrConstants.js23
-rw-r--r--src/web/js/dispatcher/JarrDispatcher.js16
-rw-r--r--src/web/js/dispatcher/__tests__/AppDispatcher-test.js72
-rw-r--r--src/web/js/stores/MenuStore.js118
-rw-r--r--src/web/js/stores/MiddlePanelStore.js132
-rw-r--r--src/web/js/stores/RightPanelStore.js77
-rw-r--r--src/web/js/stores/__tests__/TodoStore-test.js90
17 files changed, 1957 insertions, 0 deletions
diff --git a/src/web/js/actions/MenuActions.js b/src/web/js/actions/MenuActions.js
new file mode 100644
index 00000000..b9154581
--- /dev/null
+++ b/src/web/js/actions/MenuActions.js
@@ -0,0 +1,37 @@
+var JarrDispatcher = require('../dispatcher/JarrDispatcher');
+var ActionTypes = require('../constants/JarrConstants');
+var jquery = require('jquery');
+
+
+var MenuActions = {
+ // PARENT FILTERS
+ reload: function() {
+ jquery.getJSON('/menu', function(payload) {
+ JarrDispatcher.dispatch({
+ type: ActionTypes.RELOAD_MENU,
+ feeds: payload.feeds,
+ categories: payload.categories,
+ categories_order: payload.categories_order,
+ is_admin: payload.is_admin,
+ max_error: payload.max_error,
+ error_threshold: payload.error_threshold,
+ crawling_method: payload.crawling_method,
+ all_unread_count: payload.all_unread_count,
+ });
+ });
+ },
+ setFilter: function(filter) {
+ JarrDispatcher.dispatch({
+ type: ActionTypes.MENU_FILTER,
+ filter: filter,
+ });
+ },
+ toggleAllFolding: function(all_folded) {
+ JarrDispatcher.dispatch({
+ type: ActionTypes.TOGGLE_MENU_FOLD,
+ all_folded: all_folded,
+ });
+ },
+};
+
+module.exports = MenuActions;
diff --git a/src/web/js/actions/MiddlePanelActions.js b/src/web/js/actions/MiddlePanelActions.js
new file mode 100644
index 00000000..f805b7b1
--- /dev/null
+++ b/src/web/js/actions/MiddlePanelActions.js
@@ -0,0 +1,155 @@
+var JarrDispatcher = require('../dispatcher/JarrDispatcher');
+var ActionTypes = require('../constants/JarrConstants');
+var jquery = require('jquery');
+var MiddlePanelStore = require('../stores/MiddlePanelStore');
+
+var _last_fetched_with = {};
+var shouldFetch = function(filters) {
+ return true; // FIXME disabling intelligent fetch for now, no caching better that bad one
+// if(filters.filter != null // undefined means unchanged
+// && (_last_fetched_with.filter != 'all'
+// || _last_fetched_with.filter != filters.filter)) {
+// return true;
+// }
+// if(_last_fetched_with.filter_type != null) {
+// if(_last_fetched_with.filter_type != filters.filter_type) {
+// return true;
+// }
+// if(_last_fetched_with.filter_id != filters.filter_id) {
+// return true;
+// }
+// }
+// return false;
+}
+var reloadIfNecessaryAndDispatch = function(dispath_payload) {
+ if(shouldFetch(dispath_payload)) {
+ var filters = MiddlePanelStore.getRequestFilter(
+ dispath_payload.display_search);
+ MiddlePanelStore.filter_whitelist.map(function(key) {
+ if(key in dispath_payload) {
+ filters[key] = dispath_payload[key];
+ }
+ if(filters[key] == null) {
+ delete filters[key];
+ }
+ });
+ if('display_search' in filters) {
+ delete filters['display_search'];
+ }
+ jquery.getJSON('/middle_panel', filters,
+ function(payload) {
+ dispath_payload.articles = payload.articles;
+ dispath_payload.filters = filters;
+ JarrDispatcher.dispatch(dispath_payload);
+ _last_fetched_with = MiddlePanelStore.getRequestFilter();
+ });
+ } else {
+ JarrDispatcher.dispatch(dispath_payload);
+ }
+}
+
+
+var MiddlePanelActions = {
+ reload: function() {
+ reloadIfNecessaryAndDispatch({
+ type: ActionTypes.RELOAD_MIDDLE_PANEL,
+ });
+ },
+ search: function(search) {
+ reloadIfNecessaryAndDispatch({
+ type: ActionTypes.RELOAD_MIDDLE_PANEL,
+ display_search: true,
+ query: search.query,
+ search_title: search.title,
+ search_content: search.content,
+ });
+ },
+ search_off: function() {
+ reloadIfNecessaryAndDispatch({
+ type: ActionTypes.RELOAD_MIDDLE_PANEL,
+ display_search: false,
+ });
+ },
+ removeParentFilter: function() {
+ reloadIfNecessaryAndDispatch({
+ type: ActionTypes.PARENT_FILTER,
+ filter_type: null,
+ filter_id: null,
+ });
+ },
+ setCategoryFilter: function(category_id) {
+ reloadIfNecessaryAndDispatch({
+ type: ActionTypes.PARENT_FILTER,
+ filter_type: 'category_id',
+ filter_id: category_id,
+ });
+ },
+ setFeedFilter: function(feed_id) {
+ reloadIfNecessaryAndDispatch({
+ type: ActionTypes.PARENT_FILTER,
+ filter_type: 'feed_id',
+ filter_id: feed_id,
+ });
+ },
+ setFilter: function(filter) {
+ reloadIfNecessaryAndDispatch({
+ type: ActionTypes.MIDDLE_PANEL_FILTER,
+ filter: filter,
+ });
+ },
+ changeRead: function(category_id, feed_id, article_id, new_value){
+ jquery.ajax({type: 'PUT',
+ contentType: 'application/json',
+ data: JSON.stringify({readed: new_value}),
+ url: "api/v2.0/article/" + article_id,
+ success: function () {
+ JarrDispatcher.dispatch({
+ type: ActionTypes.CHANGE_ATTR,
+ attribute: 'read',
+ value_bool: new_value,
+ value_num: new_value ? -1 : 1,
+ articles: [{article_id: article_id,
+ category_id: category_id,
+ feed_id: feed_id}],
+ });
+ },
+ });
+ },
+ changeLike: function(category_id, feed_id, article_id, new_value){
+ jquery.ajax({type: 'PUT',
+ contentType: 'application/json',
+ data: JSON.stringify({like: new_value}),
+ url: "api/v2.0/article/" + article_id,
+ success: function () {
+ JarrDispatcher.dispatch({
+ type: ActionTypes.CHANGE_ATTR,
+ attribute: 'liked',
+ value_bool: new_value,
+ value_num: new_value ? -1 : 1,
+ articles: [{article_id: article_id,
+ category_id: category_id,
+ feed_id: feed_id}],
+ });
+ },
+ });
+ },
+ markAllAsRead: function() {
+ var filters = MiddlePanelStore.getRequestFilter();
+ jquery.ajax({type: 'PUT',
+ contentType: 'application/json',
+ data: JSON.stringify(filters),
+ url: "/mark_all_as_read",
+ success: function (payload) {
+ JarrDispatcher.dispatch({
+ type: ActionTypes.CHANGE_ATTR,
+ attribute: 'read',
+ value_num: -1,
+ value_bool: true,
+ articles: payload.articles,
+ });
+ },
+ });
+ },
+};
+
+module.exports = MiddlePanelActions;
diff --git a/src/web/js/actions/RightPanelActions.js b/src/web/js/actions/RightPanelActions.js
new file mode 100644
index 00000000..47adad79
--- /dev/null
+++ b/src/web/js/actions/RightPanelActions.js
@@ -0,0 +1,38 @@
+var jquery = require('jquery');
+var JarrDispatcher = require('../dispatcher/JarrDispatcher');
+var ActionTypes = require('../constants/JarrConstants');
+var MenuActions = require('../actions/MenuActions');
+
+var RightPanelActions = {
+ loadArticle: function(article_id, was_read_before) {
+ jquery.getJSON('/getart/' + article_id,
+ function(payload) {
+ JarrDispatcher.dispatch({
+ type: ActionTypes.LOAD_ARTICLE,
+ article: payload,
+ was_read_before: was_read_before,
+ });
+ }
+ );
+ },
+ _apiReq: function(meth, id, obj_type, data, success_callback) {
+ var args = {type: meth, contentType: 'application/json',
+ url: "api/v2.0/" + obj_type + "/" + id}
+ if(data) {args.data = JSON.stringify(data);}
+ if(success_callback) {args.success = success_callback;}
+ jquery.ajax(args);
+ },
+ putObj: function(id, obj_type, fields) {
+ this._apiReq('PUT', id, obj_type, fields, MenuActions.reload);
+ },
+ delObj: function(id, obj_type, fields) {
+ this._apiReq('DELETE', id, obj_type, null, MenuActions.reload);
+ },
+ resetErrors: function(feed_id) {
+ this._apiReq('PUT', feed_id, 'feed', {error_count: 0, last_error: ''},
+ MenuActions.reload);
+
+ },
+};
+
+module.exports = RightPanelActions;
diff --git a/src/web/js/app.js b/src/web/js/app.js
new file mode 100644
index 00000000..00156670
--- /dev/null
+++ b/src/web/js/app.js
@@ -0,0 +1,18 @@
+/**
+ * Copyright (c) 2014, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ */
+
+var React = require('react');
+var ReactDOM = require('react-dom');
+
+var MainApp = require('./components/MainApp.react');
+
+ReactDOM.render(
+ <MainApp />,
+ document.getElementById('jarrapp')
+);
diff --git a/src/web/js/components/MainApp.react.js b/src/web/js/components/MainApp.react.js
new file mode 100644
index 00000000..cbdc5833
--- /dev/null
+++ b/src/web/js/components/MainApp.react.js
@@ -0,0 +1,29 @@
+var React = require('react');
+var Col = require('react-bootstrap/lib/Col');
+var Grid = require('react-bootstrap/lib/Grid');
+
+var JarrNavBar = require('./Navbar.react');
+var Menu = require('./Menu.react');
+var MiddlePanel = require('./MiddlePanel.react');
+var RightPanel = require('./RightPanel.react');
+
+
+var MainApp = React.createClass({
+ render: function() {
+ return (<div>
+ <JarrNavBar />
+ <Grid fluid id="jarr-container">
+ <Menu />
+ <Col id="middle-panel" mdOffset={3} lgOffset={2}
+ xs={4} sm={4} md={4} lg={4}>
+ <MiddlePanel.MiddlePanelFilter />
+ <MiddlePanel.MiddlePanel />
+ </Col>
+ <RightPanel />
+ </Grid>
+ </div>
+ );
+ },
+});
+
+module.exports = MainApp;
diff --git a/src/web/js/components/Menu.react.js b/src/web/js/components/Menu.react.js
new file mode 100644
index 00000000..60578f8a
--- /dev/null
+++ b/src/web/js/components/Menu.react.js
@@ -0,0 +1,286 @@
+var React = require('react');
+var Col = require('react-bootstrap/lib/Col');
+var Badge = require('react-bootstrap/lib/Badge');
+var Button = require('react-bootstrap/lib/Button');
+var ButtonGroup = require('react-bootstrap/lib/ButtonGroup');
+var Glyphicon = require('react-bootstrap/lib/Glyphicon');
+
+var MenuStore = require('../stores/MenuStore');
+var MenuActions = require('../actions/MenuActions');
+var MiddlePanelActions = require('../actions/MiddlePanelActions');
+
+var FeedItem = React.createClass({
+ propTypes: {feed_id: React.PropTypes.number.isRequired,
+ title: React.PropTypes.string.isRequired,
+ unread: React.PropTypes.number.isRequired,
+ error_count: React.PropTypes.number.isRequired,
+ icon_url: React.PropTypes.string,
+ active: React.PropTypes.bool.isRequired,
+ },
+ render: function() {
+ var icon = null;
+ var badge_unread = null;
+ if(this.props.icon_url){
+ icon = (<img width="16px" src={this.props.icon_url} />);
+ } else {
+ icon = <Glyphicon glyph="ban-circle" />;
+ }
+ if(this.props.unread){
+ badge_unread = <Badge pullRight>{this.props.unread}</Badge>;
+ }
+ var classes = "nav-feed";
+ if(this.props.active) {
+ classes += " bg-primary";
+ }
+ if(this.props.error_count >= MenuStore._datas.max_error) {
+ classes += " bg-danger";
+ } else if(this.props.error_count > MenuStore._datas.error_threshold) {
+ classes += " bg-warning";
+ }
+ var title = <span className="title">{this.props.title}</span>;
+ return (<li className={classes} onClick={this.handleClick}>
+ {icon}{title}{badge_unread}
+ </li>
+ );
+ },
+ handleClick: function() {
+ MiddlePanelActions.setFeedFilter(this.props.feed_id);
+ },
+});
+
+var Category = React.createClass({
+ propTypes: {category_id: React.PropTypes.number,
+ active_type: React.PropTypes.string,
+ active_id: React.PropTypes.number},
+ render: function() {
+ var classes = "nav-cat";
+ if((this.props.active_type == 'category_id'
+ || this.props.category_id == null)
+ && this.props.active_id == this.props.category_id) {
+ classes += " bg-primary";
+ }
+ return (<li className={classes} onClick={this.handleClick}>
+ {this.props.children}
+ </li>
+ );
+ },
+ handleClick: function(evnt) {
+ // hack to avoid selection when clicking on folding icon
+ if(!evnt.target.classList.contains('glyphicon')) {
+ if(this.props.category_id != null) {
+ MiddlePanelActions.setCategoryFilter(this.props.category_id);
+ } else {
+ MiddlePanelActions.removeParentFilter();
+ }
+ }
+ },
+});
+
+var CategoryGroup = React.createClass({
+ propTypes: {cat_id: React.PropTypes.number.isRequired,
+ filter: React.PropTypes.string.isRequired,
+ active_type: React.PropTypes.string,
+ active_id: React.PropTypes.number,
+ name: React.PropTypes.string.isRequired,
+ feeds: React.PropTypes.array.isRequired,
+ unread: React.PropTypes.number.isRequired,
+ folded: React.PropTypes.bool.isRequired,
+ },
+ getInitialState: function() {
+ return {folded: this.props.folded};
+ },
+ componentWillReceiveProps: function(nextProps) {
+ this.setState({folded: nextProps.folded});
+ },
+ render: function() {
+ // hidden the no category if empty
+ if(!this.props.cat_id && !this.props.feeds.length) {
+ return <ul className="hidden" />;
+ }
+ var filter = this.props.filter;
+ var a_type = this.props.active_type;
+ var a_id = this.props.active_id;
+ if(!this.state.folded) {
+ // filtering according to this.props.filter
+ var feeds = this.props.feeds.filter(function(feed) {
+ if (filter == 'unread' && feed.unread <= 0) {
+ return false;
+ } else if (filter == 'error' && feed.error_count <= MenuStore._datas.error_threshold) {
+ return false;
+ }
+ return true;
+ }).sort(function(feed_a, feed_b){
+ return feed_b.unread - feed_a.unread;
+ }).map(function(feed) {
+ return (<FeedItem key={"f" + feed.id} feed_id={feed.id}
+ title={feed.title} unread={feed.unread}
+ error_count={feed.error_count}
+ active={a_type == 'feed_id' && a_id == feed.id}
+ icon_url={feed.icon_url} />
+ );
+ });
+ } else {
+ var feeds = [];
+ }
+ var unread = null;
+ if(this.props.unread) {
+ unread = <Badge pullRight>{this.props.unread}</Badge>;
+ }
+ var ctrl = (<Glyphicon onClick={this.toggleFolding} pullLeft
+ glyph={this.state.folded?"menu-right":"menu-down"} />
+ );
+
+ return (<ul className="nav nav-sidebar">
+ <Category category_id={this.props.cat_id}
+ active_id={this.props.active_id}
+ active_type={this.props.active_type}>
+ {ctrl}<h4>{this.props.name}</h4>{unread}
+ </Category>
+ {feeds}
+ </ul>
+ );
+ },
+ toggleFolding: function(evnt) {
+ this.setState({folded: !this.state.folded});
+ evnt.stopPropagation();
+ },
+});
+
+var MenuFilter = React.createClass({
+ propTypes: {feed_in_error: React.PropTypes.bool,
+ filter: React.PropTypes.string.isRequired},
+ getInitialState: function() {
+ return {allFolded: false};
+ },
+ render: function() {
+ var error_button = null;
+ if (this.props.feed_in_error) {
+ error_button = (
+ <Button active={this.props.filter == "error"}
+ title="Some of your feeds are in error, click here to list them"
+ onClick={this.setErrorFilter}
+ bsSize="small" bsStyle="warning">
+ <Glyphicon glyph="exclamation-sign" />
+ </Button>
+ );
+ }
+ if(this.state.allFolded) {
+ var foldBtnTitle = "Unfold all categories";
+ var foldBtnGlyph = "option-horizontal";
+ } else {
+ var foldBtnTitle = "Fold all categories";
+ var foldBtnGlyph = "option-vertical";
+ }
+ return (<div>
+ <ButtonGroup className="nav nav-sidebar">
+ <Button active={this.props.filter == "all"}
+ title="Display all feeds"
+ onClick={this.setAllFilter} bsSize="small">
+ <Glyphicon glyph="menu-hamburger" />
+ </Button>
+ <Button active={this.props.filter == "unread"}
+ title="Display only feed with unread article"
+ onClick={this.setUnreadFilter}
+ bsSize="small">
+ <Glyphicon glyph="unchecked" />
+ </Button>
+ {error_button}
+ </ButtonGroup>
+ <ButtonGroup className="nav nav-sidebar">
+ <Button onClick={MenuActions.reload}
+ title="Refresh all feeds" bsSize="small">
+ <Glyphicon glyph="refresh" />
+ </Button>
+ </ButtonGroup>
+ <ButtonGroup className="nav nav-sidebar">
+ <Button title={foldBtnTitle} bsSize="small"
+ onClick={this.toggleFold}>
+ <Glyphicon glyph={foldBtnGlyph} />
+ </Button>
+ </ButtonGroup>
+ </div>
+ );
+ },
+ setAllFilter: function() {
+ MenuActions.setFilter("all");
+ },
+ setUnreadFilter: function() {
+ MenuActions.setFilter("unread");
+ },
+ setErrorFilter: function() {
+ MenuActions.setFilter("error");
+ },
+ toggleFold: function() {
+ this.setState({allFolded: !this.state.allFolded}, function() {
+ MenuActions.toggleAllFolding(this.state.allFolded);
+ }.bind(this));
+ },
+});
+
+var Menu = React.createClass({
+ getInitialState: function() {
+ return {filter: 'all', categories: {}, feeds: {},
+ all_folded: false, active_type: null, active_id: null};
+ },
+ render: function() {
+ var feed_in_error = false;
+ var rmPrntFilt = (
+ <ul className="nav nav-sidebar">
+ <Category category_id={null}
+ active_id={this.state.active_id}
+ active_type={this.state.active_type}>
+ <h4>All</h4>
+ </Category>
+ </ul>
+ );
+ var categories = [];
+ for(var index in this.state.categories_order) {
+ var cat_id = this.state.categories_order[index];
+ var feeds = [];
+ var unread = 0;
+ this.state.categories[cat_id].feeds.map(function(feed_id) {
+ if(this.state.feeds[feed_id].error_count > MenuStore._datas.error_threshold) {
+ feed_in_error = true;
+ }
+ unread += this.state.feeds[feed_id].unread;
+ feeds.push(this.state.feeds[feed_id]);
+ }.bind(this));
+ categories.push(<CategoryGroup key={"c" + cat_id} feeds={feeds}
+ filter={this.state.filter}
+ active_type={this.state.active_type}
+ active_id={this.state.active_id}
+ name={this.state.categories[cat_id].name}
+ cat_id={this.state.categories[cat_id].id}
+ folded={this.state.all_folded}
+ unread={unread} />);
+ }
+
+ return (<Col xsHidden smHidden md={3} lg={2}
+ id="menu" className="sidebar">
+ <MenuFilter filter={this.state.filter}
+ feed_in_error={feed_in_error} />
+ {rmPrntFilt}
+ {categories}
+ </Col>
+ );
+ },
+ componentDidMount: function() {
+ MenuActions.reload();
+ MenuStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ MenuStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var datas = MenuStore.getAll();
+ this.setState({filter: datas.filter,
+ feeds: datas.feeds,
+ categories: datas.categories,
+ categories_order: datas.categories_order,
+ active_type: datas.active_type,
+ active_id: datas.active_id,
+ all_folded: datas.all_folded});
+ },
+});
+
+module.exports = Menu;
diff --git a/src/web/js/components/MiddlePanel.react.js b/src/web/js/components/MiddlePanel.react.js
new file mode 100644
index 00000000..e251447e
--- /dev/null
+++ b/src/web/js/components/MiddlePanel.react.js
@@ -0,0 +1,261 @@
+var React = require('react');
+
+var Row = require('react-bootstrap/lib/Row');
+var Button = require('react-bootstrap/lib/Button');
+var ButtonGroup = require('react-bootstrap/lib/ButtonGroup');
+var Glyphicon = require('react-bootstrap/lib/Glyphicon');
+
+var MiddlePanelStore = require('../stores/MiddlePanelStore');
+var MiddlePanelActions = require('../actions/MiddlePanelActions');
+var RightPanelActions = require('../actions/RightPanelActions');
+
+var JarrTime = require('./time.react');
+
+var TableLine = React.createClass({
+ propTypes: {article_id: React.PropTypes.number.isRequired,
+ feed_title: React.PropTypes.string.isRequired,
+ icon_url: React.PropTypes.string,
+ title: React.PropTypes.string.isRequired,
+ timestamp: React.PropTypes.number.isRequired,
+ date: React.PropTypes.string.isRequired,
+ read: React.PropTypes.bool.isRequired,
+ selected: React.PropTypes.bool.isRequired,
+ liked: React.PropTypes.bool.isRequired,
+ },
+ getInitialState: function() {
+ return {read: this.props.read, liked: this.props.liked,
+ selected: false};
+ },
+ render: function() {
+ var liked = this.state.liked ? 'l' : '';
+ var icon = null;
+ if(this.props.icon_url){
+ icon = (<img width="16px" src={this.props.icon_url} />);
+ } else {
+ icon = <Glyphicon glyph="ban-circle" />;
+ }
+ var title = (<a href={'/article/redirect/' + this.props.article_id}
+ onClick={this.openRedirectLink}>
+ {icon} {this.props.feed_title}
+ </a>);
+ var read = (<Glyphicon glyph={this.state.read?"check":"unchecked"}
+ onClick={this.toogleRead} />);
+ var liked = (<Glyphicon glyph={this.state.liked?"star":"star-empty"}
+ onClick={this.toogleLike} />);
+ var clsses = "list-group-item";
+ if(this.props.selected) {
+ clsses += " active";
+ }
+ // FIXME https://github.com/yahoo/react-intl/issues/189
+ // use FormattedRelative when fixed, will have to upgrade to ReactIntlv2
+ return (<div className={clsses} onClick={this.loadArticle}>
+ <h5><strong>{title}</strong></h5>
+ <JarrTime text={this.props.date}
+ stamp={this.props.timestamp} />
+ <div>{read} {liked} {this.props.title}</div>
+ </div>
+ );
+ },
+ openRedirectLink: function(evnt) {
+ if(!this.state.read) {
+ this.toogleRead(evnt);
+ }
+ },
+ toogleRead: function(evnt) {
+ this.setState({read: !this.state.read}, function() {
+ MiddlePanelActions.changeRead(this.props.category_id,
+ this.props.feed_id, this.props.article_id, this.state.read);
+ }.bind(this));
+ evnt.stopPropagation();
+ },
+ toogleLike: function(evnt) {
+ this.setState({liked: !this.state.liked}, function() {
+ MiddlePanelActions.changeLike(this.props.category_id,
+ this.props.feed_id, this.props.article_id, this.state.liked);
+ }.bind(this));
+ evnt.stopPropagation();
+ },
+ loadArticle: function() {
+ this.setState({active: true, read: true}, function() {
+ RightPanelActions.loadArticle(
+ this.props.article_id, this.props.read);
+ }.bind(this));
+ },
+ stopPropagation: function(evnt) {
+ evnt.stopPropagation();
+ },
+});
+
+var MiddlePanelSearchRow = React.createClass({
+ getInitialState: function() {
+ return {query: MiddlePanelStore._datas.query,
+ search_title: MiddlePanelStore._datas.search_title,
+ search_content: MiddlePanelStore._datas.search_content,
+ };
+ },
+ render: function() {
+ return (<Row>
+ <form onSubmit={this.launchSearch}>
+ <div className="input-group input-group-sm">
+ <span className="input-group-addon">
+ <span onClick={this.toogleSTitle}>Title</span>
+ <input id="search-title" type="checkbox"
+ onChange={this.toogleSTitle}
+ checked={this.state.search_title}
+ aria-label="Search title" />
+ </span>
+ <span className="input-group-addon">
+ <span onClick={this.toogleSContent}>Content</span>
+ <input id="search-content" type="checkbox"
+ onChange={this.toogleSContent}
+ checked={this.state.search_content}
+ aria-label="Search content" />
+ </span>
+ <input type="text" className="form-control"
+ onChange={this.setQuery}
+ placeholder="Search text" />
+ </div>
+ </form>
+ </Row>
+ );
+ },
+ setQuery: function(evnt) {
+ this.setState({query: evnt.target.value});
+ },
+ toogleSTitle: function() {
+ this.setState({search_title: !this.state.search_title},
+ this.launchSearch);
+ },
+ toogleSContent: function() {
+ this.setState({search_content: !this.state.search_content},
+ this.launchSearch);
+ },
+ launchSearch: function(evnt) {
+ if(this.state.query && (this.state.search_title
+ || this.state.search_content)) {
+ MiddlePanelActions.search({query: this.state.query,
+ title: this.state.search_title,
+ content: this.state.search_content});
+ }
+ if(evnt) {
+ evnt.preventDefault();
+ }
+ },
+});
+
+var MiddlePanelFilter = React.createClass({
+ getInitialState: function() {
+ return {filter: MiddlePanelStore._datas.filter,
+ display_search: MiddlePanelStore._datas.display_search};
+ },
+ render: function() {
+ var search_row = null;
+ if(this.state.display_search) {
+ search_row = <MiddlePanelSearchRow />
+ }
+ return (<div>
+ <Row className="show-grid">
+ <ButtonGroup>
+ <Button active={this.state.filter == "all"}
+ title="Display all articles"
+ onClick={this.setAllFilter} bsSize="small">
+ <Glyphicon glyph="menu-hamburger" />
+ </Button>
+ <Button active={this.state.filter == "unread"}
+ title="Display only unread article"
+ onClick={this.setUnreadFilter}
+ bsSize="small">
+ <Glyphicon glyph="unchecked" />
+ </Button>
+ <Button active={this.state.filter == "liked"}
+ title="Filter only liked articles"
+ onClick={this.setLikedFilter}
+ bsSize="small">
+ <Glyphicon glyph="star" />
+ </Button>
+ </ButtonGroup>
+ <ButtonGroup>
+ <Button onClick={this.toogleSearch}
+ title="Search through displayed articles"
+ bsSize="small">
+ <Glyphicon glyph="search" />
+ </Button>
+ </ButtonGroup>
+ <ButtonGroup>
+ <Button onClick={MiddlePanelActions.markAllAsRead}
+ title="Mark all displayed article as read"
+ bsSize="small">
+ <Glyphicon glyph="trash" />
+ </Button>
+ </ButtonGroup>
+ </Row>
+ {search_row}
+ </div>
+ );
+ },
+ setAllFilter: function() {
+ this.setState({filter: 'all'}, function() {
+ MiddlePanelActions.setFilter('all');
+ }.bind(this));
+ },
+ setUnreadFilter: function() {
+ this.setState({filter: 'unread'}, function() {
+ MiddlePanelActions.setFilter('unread');
+ }.bind(this));
+ },
+ setLikedFilter: function() {
+ this.setState({filter: 'liked'}, function() {
+ MiddlePanelActions.setFilter('liked');
+ }.bind(this));
+ },
+ toogleSearch: function() {
+ this.setState({display_search: !this.state.display_search},
+ function() {
+ if(!this.state.display_search) {
+ MiddlePanelActions.search_off();
+ }
+ }.bind(this)
+ );
+ },
+});
+
+var MiddlePanel = React.createClass({
+ getInitialState: function() {
+ return {filter: MiddlePanelStore._datas.filter, articles: []};
+ },
+ render: function() {
+ return (<Row className="show-grid">
+ <div className="list-group">
+ {this.state.articles.map(function(article){
+ return (<TableLine key={"a" + article.article_id}
+ title={article.title}
+ icon_url={article.icon_url}
+ read={article.read}
+ liked={article.liked}
+ timestamp={article.timestamp}
+ date={article.date}
+ selected={article.selected}
+ article_id={article.article_id}
+ feed_id={article.feed_id}
+ locales={['en']}
+ category_id={article.category_id}
+ feed_title={article.feed_title} />);})}
+ </div>
+ </Row>
+ );
+ },
+ componentDidMount: function() {
+ MiddlePanelActions.reload();
+ MiddlePanelStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ MiddlePanelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ this.setState({filter: MiddlePanelStore._datas.filter,
+ articles: MiddlePanelStore.getArticles()});
+ },
+});
+
+module.exports = {MiddlePanel: MiddlePanel,
+ MiddlePanelFilter: MiddlePanelFilter};
diff --git a/src/web/js/components/Navbar.react.js b/src/web/js/components/Navbar.react.js
new file mode 100644
index 00000000..57c38900
--- /dev/null
+++ b/src/web/js/components/Navbar.react.js
@@ -0,0 +1,138 @@
+var React = require('react');
+var Glyphicon = require('react-bootstrap/lib/Glyphicon');
+var Nav = require('react-bootstrap/lib/Nav');
+var NavItem = require('react-bootstrap/lib/NavItem');
+var Navbar = require('react-bootstrap/lib/Navbar');
+var NavDropdown = require('react-bootstrap/lib/NavDropdown');
+var MenuItem = require('react-bootstrap/lib/MenuItem');
+var Modal = require('react-bootstrap/lib/Modal');
+var Button = require('react-bootstrap/lib/Button');
+var Input = require('react-bootstrap/lib/Input');
+
+var MenuStore = require('../stores/MenuStore');
+
+JarrNavBar = React.createClass({
+ getInitialState: function() {
+ return {is_admin: MenuStore._datas.is_admin,
+ crawling_method: MenuStore._datas.crawling_method,
+ showModal: false, modalType: null};
+ },
+ buttonFetch: function() {
+ if(this.state.is_admin && this.state.crawling_method != 'http') {
+ return (<NavItem eventKey={2} href="/fetch">
+ <Glyphicon glyph="import" />Fetch
+ </NavItem>);
+ }
+ },
+ buttonAdmin: function() {
+ if(this.state.is_admin) {
+ return (<NavDropdown title={<Glyphicon glyph='cog' />}
+ id='admin-dropdown'>
+ <MenuItem href="/admin/dashboard">
+ <Glyphicon glyph="dashboard" />Dashboard
+ </MenuItem>
+ </NavDropdown>);
+ }
+ },
+ getModal: function() {
+ var heading = null;
+ var action = null;
+ var body = null;
+ if(this.state.modalType == 'addFeed') {
+ heading = 'Add a new feed';
+ action = '/feed/bookmarklet';
+ placeholder = "Site or feed url, we'll sort it out later ;)";
+ body = <Input name="url" type="text" placeholder={placeholder} />;
+ } else {
+ heading = 'Add a new category';
+ action = '/category/create';
+ body = <Input name="name" type="text"
+ placeholder="Name, there isn't much more to it" />;
+ }
+ return (<Modal show={this.state.showModal} onHide={this.close}>
+ <form action={action} method="POST">
+ <Modal.Header closeButton>
+ <Modal.Title>{heading}</Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ {body}
+ </Modal.Body>
+ <Modal.Footer>
+ <Button type="submit">Add</Button>
+ </Modal.Footer>
+ </form>
+ </Modal>);
+ },
+ close: function() {
+ this.setState({showModal: false, modalType: null});
+ },
+ openAddFeed: function() {
+ this.setState({showModal: true, modalType: 'addFeed'});
+ },
+ openAddCategory: function() {
+ this.setState({showModal: true, modalType: 'addCategory'});
+ },
+ render: function() {
+ return (<Navbar fixedTop inverse id="jarrnav">
+ {this.getModal()}
+ <Navbar.Header>
+ <Navbar.Brand>
+ <a href="/">JARR</a>
+ </Navbar.Brand>
+ <Navbar.Toggle />
+ </Navbar.Header>
+ <Nav pullRight>
+ {this.buttonFetch()}
+ <NavItem className="jarrnavitem"
+ onClick={this.openAddFeed} href="#">
+ <Glyphicon glyph="plus-sign" />Add a new feed
+ </NavItem>
+ <NavItem className="jarrnavitem"
+ onClick={this.openAddCategory} href="#">
+ <Glyphicon glyph="plus-sign" />Add a new category
+ </NavItem>
+ <NavDropdown title="Feed" id="feed-dropdown">
+ <MenuItem href="/feeds/inactives">
+ Inactives
+ </MenuItem>
+ <MenuItem href="/articles/history">
+ History
+ </MenuItem>
+ <MenuItem href="/feeds/">
+ All
+ </MenuItem>
+ </NavDropdown>
+ {this.buttonAdmin()}
+ <NavDropdown title={<Glyphicon glyph='user' />}
+ id="user-dropdown">
+ <MenuItem href="/user/profile">
+ <Glyphicon glyph="user" />Profile
+ </MenuItem>
+ <MenuItem href="/user/management">
+ <Glyphicon glyph="cog" />Your data
+ </MenuItem>
+ <MenuItem href="/about">
+ <Glyphicon glyph="question-sign" />About
+ </MenuItem>
+ <MenuItem href="/logout">
+ <Glyphicon glyph="log-out" />Logout
+ </MenuItem>
+ </NavDropdown>
+ </Nav>
+ </Navbar>
+ );
+ },
+ componentDidMount: function() {
+ MenuStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ MenuStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ var datas = MenuStore.getAll();
+ this.setState({is_admin: datas.is_admin,
+ crawling_method: datas.crawling_method});
+ },
+});
+
+module.exports = JarrNavBar;
diff --git a/src/web/js/components/RightPanel.react.js b/src/web/js/components/RightPanel.react.js
new file mode 100644
index 00000000..7af0aa75
--- /dev/null
+++ b/src/web/js/components/RightPanel.react.js
@@ -0,0 +1,452 @@
+var React = require('react');
+var Col = require('react-bootstrap/lib/Col');
+var Glyphicon = require('react-bootstrap/lib/Glyphicon');
+var Button = require('react-bootstrap/lib/Button');
+var ButtonGroup = require('react-bootstrap/lib/ButtonGroup');
+var Modal = require('react-bootstrap/lib/Modal');
+
+var RightPanelActions = require('../actions/RightPanelActions');
+var RightPanelStore = require('../stores/RightPanelStore');
+var MenuStore = require('../stores/MenuStore');
+var JarrTime = require('./time.react');
+
+var PanelMixin = {
+ propTypes: {obj: React.PropTypes.object.isRequired},
+ getInitialState: function() {
+ return {edit_mode: false, obj: this.props.obj, showModal: false};
+ },
+ getHeader: function() {
+ var icon = null;
+ if(this.props.obj.icon_url){
+ icon = (<img width="16px" src={this.props.obj.icon_url} />);
+ }
+ var btn_grp = null;
+ if(this.isEditable() || this.isRemovable()) {
+ var edit_button = null;
+ if(this.isEditable()) {
+ edit_button = (<Button onClick={this.onClickEdit}>
+ <Glyphicon glyph="pencil" />
+ </Button>);
+ }
+ var rem_button = null;
+ if(this.isRemovable()) {
+ rem_button = (<Button onClick={this.onClickRemove}>
+ <Glyphicon glyph="remove-sign" />
+ </Button>);
+ }
+ btn_grp = (<ButtonGroup bsSize="small">
+ {edit_button}
+ {rem_button}
+ </ButtonGroup>);
+ }
+ return (<div id="right-panel-heading" className="panel-heading">
+ <Modal show={this.state.showModal} onHide={this.closeModal}>
+ <Modal.Header closeButton>
+ <Modal.Title>Are you sure ?</Modal.Title>
+ </Modal.Header>
+ <Modal.Footer>
+ <Button onClick={this.confirmDelete}>
+ Confirm
+ </Button>
+ </Modal.Footer>
+ </Modal>
+
+ <h4>{icon}{this.getTitle()}</h4>
+ {btn_grp}
+ </div>);
+ },
+ getKey: function(prefix, suffix) {
+ return ((this.state.edit_mode?'edit':'fix') + prefix
+ + '-' + this.props.obj.id + '-' + suffix);
+ },
+ getCore: function() {
+ var items = [];
+ var key;
+ if(!this.state.edit_mode) {
+ this.fields.filter(function(field) {
+ return field.type != 'ignore';
+ }).map(function(field) {
+ key = this.getKey('dt', field.key);
+ items.push(<dt key={key}>{field.title}</dt>);
+ key = this.getKey('dd', field.key);
+ if(field.type == 'string') {
+ items.push(<dd key={key}>{this.props.obj[field.key]}</dd>);
+ } else if(field.type == 'bool') {
+ if(this.props.obj[field.key]) {
+ items.push(<dd key={key}><Glyphicon glyph="ok" /></dd>);
+ } else {
+ items.push(<dd key={key}><Glyphicon glyph="pause" /></dd>);
+ }
+ } else if (field.type == 'link') {
+ items.push(<dd key={key}>
+ <a href={this.props.obj[field.key]}>
+ {this.props.obj[field.key]}
+ </a>
+ </dd>);
+ }
+ }.bind(this));
+ } else {
+ this.fields.filter(function(field) {
+ return field.type != 'ignore';
+ }).map(function(field) {
+ key = this.getKey('dd', field.key);
+ items.push(<dt key={key}>{field.title}</dt>);
+ key = this.getKey('dt', field.key);
+ var input = null;
+ if(field.type == 'string' || field.type == 'link') {
+ input = (<input type="text" name={field.key}
+ onChange={this.saveField}
+ defaultValue={this.props.obj[field.key]} />);
+ } else if (field.type == 'bool') {
+ input = (<input type="checkbox" name={field.key}
+ onChange={this.saveField}
+ defaultChecked={this.props.obj[field.key]} />);
+ }
+ items.push(<dd key={key}>{input}</dd>);
+ }.bind(this));
+ }
+ return (<dl className="dl-horizontal">{items}</dl>);
+ },
+ getSubmit: function() {
+ return (<dd key={this.getKey('dd', 'submit')}>
+ <button className="btn btn-default" onClick={this.saveObj}>
+ Submit
+ </button>
+ </dd>);
+ },
+ render: function() {
+ return (<div className="panel panel-default">
+ {this.getHeader()}
+ {this.getBody()}
+ </div>
+ );
+ },
+ onClickEdit: function() {
+ this.setState({edit_mode: !this.state.edit_mode});
+ },
+ onClickRemove: function() {
+ this.setState({showModal: true});
+ },
+ closeModal: function() {
+ this.setState({showModal: false});
+ },
+ confirmDelete: function() {
+ this.setState({showModal: false}, function() {
+ RightPanelActions.delObj(this.props.obj.id, this.obj_type);
+ }.bind(this));
+ },
+ saveField: function(evnt) {
+ var obj = this.state.obj;
+ for(var i in this.fields) {
+ if(evnt.target.name == this.fields[i].key) {
+ if(this.fields[i].type == 'bool') {
+ obj[evnt.target.name] = evnt.target.checked;
+ } else {
+ obj[evnt.target.name] = evnt.target.value;
+ }
+ break;
+ }
+ }
+ this.setState({obj: obj});
+ },
+ saveObj: function() {
+ var to_push = {};
+ this.fields.map(function(field) {
+ to_push[field.key] = this.state.obj[field.key];
+ }.bind(this));
+ this.setState({edit_mode: false}, function() {
+ RightPanelActions.putObj(this.props.obj.id, this.obj_type, to_push);
+ }.bind(this));
+ },
+};
+
+var Article = React.createClass({
+ mixins: [PanelMixin],
+ isEditable: function() {return false;},
+ isRemovable: function() {return true;},
+ fields: [{'title': 'Date', 'type': 'string', 'key': 'date'},
+ {'title': 'Original link', 'type': 'link', 'key': 'link'},
+ ],
+ obj_type: 'article',
+ getTitle: function() {return this.props.obj.title;},
+ getBody: function() {
+ return (<div className="panel-body">
+ {this.getCore()}
+ <div dangerouslySetInnerHTML={
+ {__html: this.props.obj.content}} />
+ </div>);
+ },
+});
+
+var Feed = React.createClass({
+ mixins: [PanelMixin],
+ isEditable: function() {return true;},
+ isRemovable: function() {return true;},
+ obj_type: 'feed',
+ fields: [{'title': 'Feed title', 'type': 'string', 'key': 'title'},
+ {'title': 'Description', 'type': 'string', 'key': 'description'},
+ {'title': 'Feed link', 'type': 'link', 'key': 'link'},
+ {'title': 'Site link', 'type': 'link', 'key': 'site_link'},
+ {'title': 'Enabled', 'type': 'bool', 'key': 'enabled'},
+ {'title': 'Filters', 'type': 'ignore', 'key': 'filters'},
+ {'title': 'Category', 'type': 'ignore', 'key': 'category_id'},
+ ],
+ getTitle: function() {return this.props.obj.title;},
+ getFilterRow: function(i, filter) {
+ return (<dd key={'d' + i + '-' + this.props.obj.id}
+ className="input-group filter-row">
+ <span className="input-group-btn">
+ <button className="btn btn-default" type="button"
+ data-index={i} onClick={this.removeFilterRow}>
+ <Glyphicon glyph='minus' />
+ </button>
+ </span>
+ <select name="action on" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter['action on']}>
+ <option value="match">match</option>
+ <option value="no match">no match</option>
+ </select>
+ <input name="pattern" type="text" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter.pattern} />
+ <select name="type" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter.type}>
+ <option value='simple match'>simple match</option>
+ <option value='regex'>regex</option>
+ </select>
+ <select name="action" className="form-control"
+ data-index={i} onChange={this.saveFilterChange}
+ defaultValue={filter.action}>
+ <option value="mark as read">mark as read</option>
+ <option value="mark as favorite">mark as favorite</option>
+ </select>
+ </dd>);
+ },
+ getFilterRows: function() {
+ var rows = [];
+ if(this.state.edit_mode) {
+ for(var i in this.state.obj.filters) {
+ rows.push(this.getFilterRow(i, this.state.obj.filters[i]));
+ }
+ return (<dl className="dl-horizontal">
+ <dt>Filters</dt>
+ <dd>
+ <button className="btn btn-default"
+ type="button" onClick={this.addFilterRow}>
+ <Glyphicon glyph='plus' />
+ </button>
+ </dd>
+ {rows}
+ </dl>);
+ }
+ rows = [];
+ rows.push(<dt key={'d-title'}>Filters</dt>);
+ for(var i in this.state.obj.filters) {
+ rows.push(<dd key={'d' + i}>
+ When {this.state.obj.filters[i]['action on']} on "{this.state.obj.filters[i].pattern}" ({this.state.obj.filters[i].type}) => {this.state.obj.filters[i].action}
+ </dd>);
+ }
+ return <dl className="dl-horizontal">{rows}</dl>;
+ },
+ getCategorySelect: function() {
+ var content = null;
+ if(this.state.edit_mode) {
+ var categ_options = [];
+ for(var index in MenuStore._datas.categories_order) {
+ var cat_id = MenuStore._datas.categories_order[index];
+ categ_options.push(
+ <option value={cat_id}
+ key={MenuStore._datas.categories[cat_id].id}>
+ {MenuStore._datas.categories[cat_id].name}
+ </option>);
+ }
+ content = (<select name="category_id" className="form-control"
+ onChange={this.saveField}
+ defaultValue={this.props.obj.category_id}>
+ {categ_options}
+ </select>);
+ } else {
+ content = MenuStore._datas.categories[this.props.obj.category_id].name;
+ }
+ return (<dl className="dl-horizontal">
+ <dt>Category</dt><dd>{content}</dd>
+ </dl>);
+ },
+ getErrorFields: function() {
+ if(this.props.obj.error_count < MenuStore._datas.error_threshold) {
+ return;
+ }
+ if(this.props.obj.error_count < MenuStore._datas.max_error) {
+ return (<dl className="dl-horizontal">
+ <dt>State</dt>
+ <dd>The download of this feed has encountered some problems. However its error counter will be reinitialized at the next successful retrieving.</dd>
+ <dt>Last error</dt>
+ <dd>{this.props.obj.last_error}</dd>
+ </dl>);
+ }
+ return (<dl className="dl-horizontal">
+ <dt>State</dt>
+ <dd>That feed has encountered too much consecutive errors and won't be retrieved anymore.</dd>
+
+ <dt>Last error</dt>
+ <dd>{this.props.obj.last_error}</dd>
+ <dd>
+ <Button onClick={this.resetErrors}>Reset error count
+ </Button>
+ </dd>
+ </dl>);
+
+ },
+ resetErrors: function() {
+ var obj = this.state.obj;
+ obj.error_count = 0;
+ this.setState({obj: obj}, function() {
+ RightPanelActions.resetErrors(this.props.obj.id);
+ }.bind(this));
+ },
+ getBody: function() {
+ return (<div className="panel-body">
+ <dl className="dl-horizontal">
+ <dt>Created on</dt>
+ <dd><JarrTime stamp={this.props.obj.created_stamp}
+ text={this.props.obj.created_date} />
+ </dd>
+ <dt>Last fetched</dt>
+ <dd><JarrTime stamp={this.props.obj.last_stamp}
+ text={this.props.obj.last_retrieved} />
+ </dd>
+ </dl>
+ {this.getErrorFields()}
+ {this.getCategorySelect()}
+ {this.getCore()}
+ {this.getFilterRows()}
+ {this.state.edit_mode?this.getSubmit():null}
+ </div>
+ );
+ },
+ addFilterRow: function() {
+ var obj = this.state.obj;
+ obj.filters.push({action: "mark as read", 'action on': "match",
+ type: "simple match", pattern: ""});
+ this.setState({obj: obj});
+ },
+ removeFilterRow: function(evnt) {
+ var obj = this.state.obj;
+ delete obj.filters[evnt.target.getAttribute('data-index')];
+ this.setState({obj: obj});
+ },
+ saveFilterChange: function(evnt) {
+ var index = evnt.target.getAttribute('data-index');
+ var obj = this.state.obj;
+ obj.filters[index][evnt.target.name] = evnt.target.value;
+ this.setState({obj: obj});
+ },
+});
+
+var Category = React.createClass({
+ mixins: [PanelMixin],
+ isEditable: function() {
+ if(this.props.obj.id != 0) {return true;}
+ else {return false;}
+ },
+ isRemovable: function() {return this.isEditable();},
+ obj_type: 'category',
+ fields: [{'title': 'Category name', 'type': 'string', 'key': 'name'}],
+ getTitle: function() {return this.props.obj.name;},
+ getBody: function() {
+ return (<div className="panel-body">
+ {this.getCore()}
+ {this.state.edit_mode?this.getSubmit():null}
+ </div>);
+ },
+});
+
+var RightPanel = React.createClass({
+ getInitialState: function() {
+ return {category: null, feed: null, article: null, current: null};
+ },
+ getCategoryCrum: function() {
+ return (<li><a onClick={this.selectCategory} href="#">
+ {this.state.category.name}
+ </a></li>);
+ },
+ getFeedCrum: function() {
+ return (<li><a onClick={this.selectFeed} href="#">
+ {this.state.feed.title}
+ </a></li>);
+ },
+ getArticleCrum: function() {
+ return <li>{this.state.article.title}</li>;
+ },
+ render: function() {
+ window.scrollTo(0, 0);
+ var brd_category = null;
+ var brd_feed = null;
+ var brd_article = null;
+ var breadcrum = null;
+ if(this.state.category) {
+ brd_category = (<li className="rp-crum">
+ <a onClick={this.selectCategory} href="#">
+ {this.state.category.name}
+ </a>
+ </li>);
+ }
+ if(this.state.feed) {
+ brd_feed = (<li className="rp-crum">
+ <a onClick={this.selectFeed} href="#">
+ {this.state.feed.title}
+ </a>
+ </li>);
+ }
+ if(this.state.article) {
+ brd_article = <li className="rp-crum">{this.state.article.title}</li>;
+ }
+ if(brd_category || brd_feed || brd_article) {
+ breadcrum = (<ol className="breadcrumb" id="rp-breadcrum">
+ {brd_category}
+ {brd_feed}
+ {brd_article}
+ </ol>);
+ }
+ if(this.state.current == 'article') {
+ var cntnt = (<Article type='article' obj={this.state.article}
+ key={this.state.article.id} />);
+ } else if(this.state.current == 'feed') {
+ var cntnt = (<Feed type='feed' obj={this.state.feed}
+ key={this.state.feed.id} />);
+ } else if(this.state.current == 'category') {
+ var cntnt = (<Category type='category' obj={this.state.category}
+ key={this.state.category.id} />);
+ }
+
+ return (<Col id="right-panel" xsOffset={4} smOffset={4}
+ mdOffset={7} lgOffset={6}
+ xs={8} sm={8} md={5} lg={6}>
+ {breadcrum}
+ {cntnt}
+ </Col>
+ );
+ },
+ selectCategory: function() {
+ this.setState({current: 'category'});
+ },
+ selectFeed: function() {
+ this.setState({current: 'feed'});
+ },
+ selectArticle: function() {
+ this.setState({current: 'article'});
+ },
+ componentDidMount: function() {
+ RightPanelStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ RightPanelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ this.setState(RightPanelStore.getAll());
+ },
+});
+
+module.exports = RightPanel;
diff --git a/src/web/js/components/time.react.js b/src/web/js/components/time.react.js
new file mode 100644
index 00000000..8b4d47d9
--- /dev/null
+++ b/src/web/js/components/time.react.js
@@ -0,0 +1,15 @@
+var React = require('react');
+var ReactIntl = require('react-intl');
+
+var JarrTime = React.createClass({
+ mixins: [ReactIntl.IntlMixin],
+ propTypes: {stamp: React.PropTypes.number.isRequired,
+ text: React.PropTypes.string.isRequired},
+ render: function() {
+ return (<time dateTime={this.props.text} title={this.props.text}>
+ {this.formatRelative(this.props.stamp)}
+ </time>);
+ },
+});
+
+module.exports = JarrTime;
diff --git a/src/web/js/constants/JarrConstants.js b/src/web/js/constants/JarrConstants.js
new file mode 100644
index 00000000..0ea42aad
--- /dev/null
+++ b/src/web/js/constants/JarrConstants.js
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2014-2015, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * TodoConstants
+ */
+
+var keyMirror = require('keymirror');
+
+module.exports = keyMirror({
+ TOGGLE_MENU_FOLD: null,
+ RELOAD_MENU: null,
+ PARENT_FILTER: null,
+ MENU_FILTER: null,
+ CHANGE_ATTR: null,
+ RELOAD_MIDDLE_PANEL: null,
+ MIDDLE_PANEL_FILTER: null,
+ LOAD_ARTICLE: null,
+});
diff --git a/src/web/js/dispatcher/JarrDispatcher.js b/src/web/js/dispatcher/JarrDispatcher.js
new file mode 100644
index 00000000..56da186f
--- /dev/null
+++ b/src/web/js/dispatcher/JarrDispatcher.js
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2014-2015, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * AppDispatcher
+ *
+ * A singleton that operates as the central hub for application updates.
+ */
+
+var Dispatcher = require('flux').Dispatcher;
+
+module.exports = new Dispatcher();
diff --git a/src/web/js/dispatcher/__tests__/AppDispatcher-test.js b/src/web/js/dispatcher/__tests__/AppDispatcher-test.js
new file mode 100644
index 00000000..d3a35fc5
--- /dev/null
+++ b/src/web/js/dispatcher/__tests__/AppDispatcher-test.js
@@ -0,0 +1,72 @@
+"use strict";
+
+jest.autoMockOff();
+
+describe('AppDispatcher', function() {
+ var AppDispatcher;
+
+ beforeEach(function() {
+ AppDispatcher = require('../AppDispatcher.js');
+ });
+
+ it('sends actions to subscribers', function() {
+ var listener = jest.genMockFunction();
+ AppDispatcher.register(listener);
+
+ var payload = {};
+ AppDispatcher.dispatch(payload);
+ expect(listener.mock.calls.length).toBe(1);
+ expect(listener.mock.calls[0][0]).toBe(payload);
+ });
+
+ it('waits with chained dependencies properly', function() {
+ var payload = {};
+
+ var listener1Done = false;
+ var listener1 = function(pl) {
+ AppDispatcher.waitFor([index2, index4]);
+ // Second, third, and fourth listeners should have now been called
+ expect(listener2Done).toBe(true);
+ expect(listener3Done).toBe(true);
+ expect(listener4Done).toBe(true);
+ listener1Done = true;
+ };
+ var index1 = AppDispatcher.register(listener1);
+
+ var listener2Done = false;
+ var listener2 = function(pl) {
+ AppDispatcher.waitFor([index3]);
+ expect(listener3Done).toBe(true);
+ listener2Done = true;
+ };
+ var index2 = AppDispatcher.register(listener2);
+
+ var listener3Done = false;
+ var listener3 = function(pl) {
+ listener3Done = true;
+ };
+ var index3 = AppDispatcher.register(listener3);
+
+ var listener4Done = false;
+ var listener4 = function(pl) {
+ AppDispatcher.waitFor([index3]);
+ expect(listener3Done).toBe(true);
+ listener4Done = true;
+ };
+ var index4 = AppDispatcher.register(listener4);
+
+ runs(function() {
+ AppDispatcher.dispatch(payload);
+ });
+
+ waitsFor(function() {
+ return listener1Done;
+ }, "Not all subscribers were properly called", 500);
+
+ runs(function() {
+ expect(listener1Done).toBe(true);
+ expect(listener2Done).toBe(true);
+ expect(listener3Done).toBe(true);
+ });
+ });
+});
diff --git a/src/web/js/stores/MenuStore.js b/src/web/js/stores/MenuStore.js
new file mode 100644
index 00000000..49c61bc1
--- /dev/null
+++ b/src/web/js/stores/MenuStore.js
@@ -0,0 +1,118 @@
+var JarrDispatcher = require('../dispatcher/JarrDispatcher');
+var ActionTypes = require('../constants/JarrConstants');
+var EventEmitter = require('events').EventEmitter;
+var CHANGE_EVENT = 'change_menu';
+var assign = require('object-assign');
+
+
+var MenuStore = assign({}, EventEmitter.prototype, {
+ _datas: {filter: 'all', feeds: {}, categories: {}, categories_order: [],
+ active_type: null, active_id: null,
+ is_admin: false, crawling_method: 'classic',
+ all_unread_count: 0, max_error: 0, error_threshold: 0,
+ all_folded: false},
+ getAll: function() {
+ return this._datas;
+ },
+ setFilter: function(value) {
+ if(this._datas.filter != value) {
+ this._datas.filter = value;
+ this.emitChange();
+ }
+ },
+ setActive: function(type, value) {
+ if(this._datas.active_id != value || this._datas.active_type != type) {
+ this._datas.active_type = type;
+ this._datas.active_id = value;
+ this.emitChange();
+ }
+ },
+ readFeedArticle: function(feed_id) {
+ // TODO
+ },
+ emitChange: function() {
+ this.emit(CHANGE_EVENT);
+ },
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+});
+
+
+MenuStore.dispatchToken = JarrDispatcher.register(function(action) {
+ switch(action.type) {
+ case ActionTypes.RELOAD_MENU:
+ MenuStore._datas['feeds'] = action.feeds;
+ MenuStore._datas['categories'] = action.categories;
+ MenuStore._datas['categories_order'] = action.categories_order;
+ MenuStore._datas['is_admin'] = action.is_admin;
+ MenuStore._datas['max_error'] = action.max_error;
+ MenuStore._datas['error_threshold'] = action.error_threshold;
+ MenuStore._datas['crawling_method'] = action.crawling_method;
+ MenuStore._datas['all_unread_count'] = action.all_unread_count;
+ MenuStore.emitChange();
+ break;
+ case ActionTypes.PARENT_FILTER:
+ MenuStore.setActive(action.filter_type, action.filter_id);
+ if(action.filters && action.articles && !action.filters.query
+ && action.filters.filter == 'unread') {
+ var new_unread = {};
+ action.articles.map(function(article) {
+ if(!(article.feed_id in new_unread)) {
+ new_unread[article.feed_id] = 0;
+ }
+ if(!article.read) {
+ new_unread[article.feed_id] += 1;
+ }
+ });
+ var changed = false;
+ for(var feed_id in new_unread) {
+ var old_unread = MenuStore._datas.feeds[feed_id].unread;
+ if(old_unread == new_unread[feed_id]) {
+ continue;
+ }
+ changed = true;
+ MenuStore._datas.feeds[feed_id].unread = new_unread[feed_id];
+ var cat_id = MenuStore._datas.feeds[feed_id].category_id;
+ MenuStore._datas.categories[cat_id].unread -= old_unread;
+ MenuStore._datas.categories[cat_id].unread += new_unread[feed_id];
+ }
+ if(changed) {
+ MenuStore.emitChange();
+ }
+ }
+
+ break;
+ case ActionTypes.MENU_FILTER:
+ MenuStore.setFilter(action.filter);
+ break;
+ case ActionTypes.CHANGE_ATTR:
+ if(action.attribute != 'read') {
+ return;
+ }
+ var val = action.value_num;
+ action.articles.map(function(article) {
+ MenuStore._datas.categories[article.category_id].unread += val;
+ MenuStore._datas.feeds[article.feed_id].unread += val;
+ });
+ MenuStore.emitChange();
+ break;
+ case ActionTypes.LOAD_ARTICLE:
+ if(!action.was_read_before) {
+ MenuStore._datas.categories[action.article.category_id].unread -= 1;
+ MenuStore._datas.feeds[action.article.feed_id].unread -= 1;
+ MenuStore.emitChange();
+ }
+ break;
+ case ActionTypes.TOGGLE_MENU_FOLD:
+ MenuStore._datas.all_folded = action.all_folded;
+ MenuStore.emitChange();
+ default:
+ // do nothing
+ }
+});
+
+module.exports = MenuStore;
diff --git a/src/web/js/stores/MiddlePanelStore.js b/src/web/js/stores/MiddlePanelStore.js
new file mode 100644
index 00000000..4a5efd00
--- /dev/null
+++ b/src/web/js/stores/MiddlePanelStore.js
@@ -0,0 +1,132 @@
+var JarrDispatcher = require('../dispatcher/JarrDispatcher');
+var ActionTypes = require('../constants/JarrConstants');
+var EventEmitter = require('events').EventEmitter;
+var CHANGE_EVENT = 'change_middle_panel';
+var assign = require('object-assign');
+
+
+var MiddlePanelStore = assign({}, EventEmitter.prototype, {
+ filter_whitelist: ['filter', 'filter_id', 'filter_type', 'display_search',
+ 'query', 'search_title', 'search_content'],
+ _datas: {articles: [], selected_article: null,
+ filter: 'unread', filter_type: null, filter_id: null,
+ display_search: false, query: null,
+ search_title: true, search_content: false},
+ getAll: function() {
+ return this._datas;
+ },
+ getRequestFilter: function(display_search) {
+ var filters = {'filter': this._datas.filter,
+ 'filter_type': this._datas.filter_type,
+ 'filter_id': this._datas.filter_id,
+ };
+ if(display_search || (display_search == undefined && this._datas.display_search)) {
+ filters.query = this._datas.query;
+ filters.search_title = this._datas.search_title;
+ filters.search_content = this._datas.search_content;
+ };
+ return filters;
+ },
+ getArticles: function() {
+ var key = null;
+ var id = null;
+ if (this._datas.filter_type) {
+ key = this._datas.filter_type;
+ id = this._datas.filter_id;
+ }
+ return this._datas.articles
+ .map(function(article) {
+ if(article.article_id == this._datas.selected_article) {
+ article.selected = true;
+ } else if(article.selected) {
+ article.selected = false;
+ }
+ return article;
+ }.bind(this))
+ .filter(function(article) {
+ return (article.selected || ((!key || article[key] == id)
+ && (this._datas.filter == 'all'
+ || (this._datas.filter == 'unread' && !article.read)
+ || (this._datas.filter == 'liked' && article.liked))));
+ }.bind(this));
+
+ },
+ setArticles: function(articles) {
+ if(articles || articles == []) {
+ this._datas.articles = articles;
+ return true;
+ }
+ return false;
+ },
+ registerFilter: function(action) {
+ var changed = false;
+ this.filter_whitelist.map(function(key) {
+ if(key in action && this._datas[key] != action[key]) {
+ changed = true;
+ this._datas[key] = action[key];
+ }
+ }.bind(this));
+ return changed;
+ },
+ emitChange: function() {
+ this.emit(CHANGE_EVENT);
+ },
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+});
+
+
+MiddlePanelStore.dispatchToken = JarrDispatcher.register(function(action) {
+ var changed = false;
+ switch(action.type) {
+ case ActionTypes.RELOAD_MIDDLE_PANEL:
+ changed = MiddlePanelStore.registerFilter(action);
+ changed = MiddlePanelStore.setArticles(action.articles) || changed;
+ break;
+ case ActionTypes.PARENT_FILTER:
+ changed = MiddlePanelStore.registerFilter(action);
+ changed = MiddlePanelStore.setArticles(action.articles) || changed;
+ break;
+ case ActionTypes.MIDDLE_PANEL_FILTER:
+ changed = MiddlePanelStore.registerFilter(action);
+ changed = MiddlePanelStore.setArticles(action.articles) || changed;
+ break;
+ case ActionTypes.CHANGE_ATTR:
+ var attr = action.attribute;
+ var val = action.value_bool;
+ action.articles.map(function(article) {
+ for (var i in MiddlePanelStore._datas.articles) {
+ if(MiddlePanelStore._datas.articles[i].article_id == article.article_id) {
+ if (MiddlePanelStore._datas.articles[i][attr] != val) {
+ MiddlePanelStore._datas.articles[i][attr] = val;
+ // avoiding redraw if not filter, display won't change anyway
+ if(MiddlePanelStore._datas.filter != 'all') {
+ changed = true;
+ }
+ }
+ break;
+ }
+ }
+ });
+ break;
+ case ActionTypes.LOAD_ARTICLE:
+ changed = true;
+ MiddlePanelStore._datas.selected_article = action.article.id;
+ for (var i in MiddlePanelStore._datas.articles) {
+ if(MiddlePanelStore._datas.articles[i].article_id == action.article.id) {
+ MiddlePanelStore._datas.articles[i].read = true;
+ break;
+ }
+ }
+ break;
+ default:
+ // pass
+ }
+ if(changed) {MiddlePanelStore.emitChange();}
+});
+
+module.exports = MiddlePanelStore;
diff --git a/src/web/js/stores/RightPanelStore.js b/src/web/js/stores/RightPanelStore.js
new file mode 100644
index 00000000..6c268dfd
--- /dev/null
+++ b/src/web/js/stores/RightPanelStore.js
@@ -0,0 +1,77 @@
+var JarrDispatcher = require('../dispatcher/JarrDispatcher');
+var ActionTypes = require('../constants/JarrConstants');
+var EventEmitter = require('events').EventEmitter;
+var CHANGE_EVENT = 'change_middle_panel';
+var assign = require('object-assign');
+var MenuStore = require('../stores/MenuStore');
+
+
+var RightPanelStore = assign({}, EventEmitter.prototype, {
+ category: null,
+ feed: null,
+ article: null,
+ current: null,
+ getAll: function() {
+ return {category: this.category, feed: this.feed,
+ article: this.article, current: this.current};
+ },
+ emitChange: function() {
+ this.emit(CHANGE_EVENT);
+ },
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+});
+
+
+RightPanelStore.dispatchToken = JarrDispatcher.register(function(action) {
+ switch(action.type) {
+ case ActionTypes.PARENT_FILTER:
+ RightPanelStore.article = null;
+ if(action.filter_id == null) {
+ RightPanelStore.category = null;
+ RightPanelStore.feed = null;
+ RightPanelStore.current = null;
+ } else if(action.filter_type == 'category_id') {
+ RightPanelStore.category = MenuStore._datas.categories[action.filter_id];
+ RightPanelStore.feed = null;
+ RightPanelStore.current = 'category';
+ RightPanelStore.emitChange();
+ } else {
+
+ RightPanelStore.feed = MenuStore._datas.feeds[action.filter_id];
+ RightPanelStore.category = MenuStore._datas.categories[RightPanelStore.feed.category_id];
+ RightPanelStore.current = 'feed';
+ RightPanelStore.emitChange();
+ }
+ break;
+ case ActionTypes.LOAD_ARTICLE:
+ RightPanelStore.feed = MenuStore._datas.feeds[action.article.feed_id];
+ RightPanelStore.category = MenuStore._datas.categories[action.article.category_id];
+ RightPanelStore.article = action.article;
+ RightPanelStore.current = 'article';
+ RightPanelStore.emitChange();
+ break;
+ case ActionTypes.RELOAD_MENU:
+ RightPanelStore.article = null;
+ if(RightPanelStore.category && !(RightPanelStore.category.id.toString() in action.categories)) {
+ RightPanelStore.category = null;
+ RightPanelStore.current = null;
+ }
+ if(RightPanelStore.feed && !(RightPanelStore.feed.id.toString() in action.feeds)) {
+ RightPanelStore.feed = null;
+ RightPanelStore.current = null;
+ }
+ if(RightPanelStore.current == 'article') {
+ RightPanelStore.current = null;
+ }
+ RightPanelStore.emitChange();
+ default:
+ // pass
+ }
+});
+
+module.exports = RightPanelStore;
diff --git a/src/web/js/stores/__tests__/TodoStore-test.js b/src/web/js/stores/__tests__/TodoStore-test.js
new file mode 100644
index 00000000..6da6cd3c
--- /dev/null
+++ b/src/web/js/stores/__tests__/TodoStore-test.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2014-2015, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * TodoStore-test
+ */
+
+jest.dontMock('../../constants/TodoConstants');
+jest.dontMock('../TodoStore');
+jest.dontMock('object-assign');
+
+describe('TodoStore', function() {
+
+ var TodoConstants = require('../../constants/TodoConstants');
+ var AppDispatcher;
+ var TodoStore;
+ var callback;
+
+ // mock actions
+ var actionTodoCreate = {
+ actionType: TodoConstants.TODO_CREATE,
+ text: 'foo'
+ };
+ var actionTodoDestroy = {
+ actionType: TodoConstants.TODO_DESTROY,
+ id: 'replace me in test'
+ };
+
+ beforeEach(function() {
+ AppDispatcher = require('../../dispatcher/AppDispatcher');
+ TodoStore = require('../TodoStore');
+ callback = AppDispatcher.register.mock.calls[0][0];
+ });
+
+ it('registers a callback with the dispatcher', function() {
+ expect(AppDispatcher.register.mock.calls.length).toBe(1);
+ });
+
+ it('should initialize with no to-do items', function() {
+ var all = TodoStore.getAll();
+ expect(all).toEqual({});
+ });
+
+ it('creates a to-do item', function() {
+ callback(actionTodoCreate);
+ var all = TodoStore.getAll();
+ var keys = Object.keys(all);
+ expect(keys.length).toBe(1);
+ expect(all[keys[0]].text).toEqual('foo');
+ });
+
+ it('destroys a to-do item', function() {
+ callback(actionTodoCreate);
+ var all = TodoStore.getAll();
+ var keys = Object.keys(all);
+ expect(keys.length).toBe(1);
+ actionTodoDestroy.id = keys[0];
+ callback(actionTodoDestroy);
+ expect(all[keys[0]]).toBeUndefined();
+ });
+
+ it('can determine whether all to-do items are complete', function() {
+ var i = 0;
+ for (; i < 3; i++) {
+ callback(actionTodoCreate);
+ }
+ expect(Object.keys(TodoStore.getAll()).length).toBe(3);
+ expect(TodoStore.areAllComplete()).toBe(false);
+
+ var all = TodoStore.getAll();
+ for (key in all) {
+ callback({
+ actionType: TodoConstants.TODO_COMPLETE,
+ id: key
+ });
+ }
+ expect(TodoStore.areAllComplete()).toBe(true);
+
+ callback({
+ actionType: TodoConstants.TODO_UNDO_COMPLETE,
+ id: key
+ });
+ expect(TodoStore.areAllComplete()).toBe(false);
+ });
+
+});
bgstack15