aboutsummaryrefslogtreecommitdiff
path: root/src/web/js/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/web/js/components')
-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
6 files changed, 1181 insertions, 0 deletions
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;
bgstack15