Instantly share code, notes, and snippets.

@hiquest /tracker_electron.md Secret
Last active Sep 22, 2017

Embed
What would you like to do?
Build a TV series tracker with Electron and React

Build a TV series tracker with Electron and React

You can't truly learn it unless you build something useful with it. That is the rule I always try to adhere to whenever I want to learn anything new. And by useful I mean a finished product or at least a proof of concept, which can be actually used by real people, the non-techy ones.

So this time when I finally got my hands on Electron, I had that idea in my head of a desktop app, where you browse shows, add your favorite ones and have a convenient dashboard with episodes coming out soon. There's an open API for that, called thetvdb, which I could easily employ. So, I gave it a go. The end result can be played with right now on GitHub.

So, in this article, I'm going to walk you through the process of using Electron with React from the very beginning. We'll start with the basics and cover the required setup for making friends with React and ES2015 (spoiler: it's soooo much easier than you probably think). Next, we'll use the React router and create some views (which are just good old web pages). Finally, we'll learn how to download data and store it in a filesystem (yes, like grown-ups!).

But first, let me tell you, why Electron is huge.

Electron overview: a beneficial mariage

Electron is a platform widely used by tech companies, including monsters like Shopify, Slack, WordPress and of cause the Github's Atom, for which it was initially developed.

In a nutshell, Electron is a combination of a browser engine and a Node.js environment. The practical meaning of this is that the environment you write code in has both: access to the DOM, and ability to work with the Node.js API. It allows the modest Web developers like you and me build the complex desktop applications, moreover platform-agnostic desktop application.

So, for example, you can do things like this

$("a").on('click', () => {
  $("body").append($(`<h1>$(process.env)</h1>`));
});

You can both: work with the app as you do with a web page and at the same time use the features of the OS like this is a native app. So you have all that flexibility of the Web combined with the power of a native app, which creates fantastic opportunities.

How to read this tutorial

This is a step-by-step tutorial. I tried to make sure that after every step we have a runnable application with a working functionality. Every step has a link you can click on to see the whole commit.

The complete code is available on GitHub, so sometimes, when I omit some chunks to save the space, don't hesitate to lookup the full version.

Enough words, let's get to action.

Step 1. Let's run an electron app

Check the whole commit.

First, let's start a new project with yarn:

mkdir tvtracker && cd tvtracker && yarn init --yes

Now let's install the electron package:

y add electron --dev

...and add the command to package.json

// package.json
// ...
  "script": {
    "start": "electron index.js"
  }
// ...

If you're not familiar with the script directive, it allows to create shortcuts to run the npm (or yarn for that matter) commands.

If you try to launch it now with yarn start, you'll get an error. That's because we don't have index.js yet. Let's create it.

// index.js

const {app, BrowserWindow} = require('electron');
const url = require('url');
const path = require('path');

let win; // global, as to protect from GC

app.on('ready', createWindow);
app.on('window-all-closed', onAllClosed);
app.on('activate', onActivated);

function createWindow () {
  win = new BrowserWindow({width: 1080, height: 600});

  loadPage();

  // release the pointer for the GC
  win.on('closed', () => win = null);
}

function loadPage() {
  // and load the index.html of the app.
  win.loadURL(url.format({
    pathname: path.join(__dirname, './src/index.html'),
    protocol: 'file:',
    slashes: true
  }));
}

function onAllClosed() {
  if (process.platform !== 'darwin') {
    app.quit();
  }
}

function onActivated() {
  if (win === null) {
    createWindow();
  }
}

OK, now this may look like too much. But actually, this is just a boilerplate, required to create a window. onActivated hack is here to simulate the OSX behavior: when you click on the app in the dock, it opens a new window but doesn't really quit when you close it. loadPage actually take the index.html file and loads it into the browser. So let's create it as well.

<!-- src/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>ElectroTV</title>
  </head>
  <body>
    <div id='root'>
      <h1>Greetings, sir!</h1>
    </div>
  </body>
</html>

Run it with yarn start and you should see our new shiny app with the greetings.

Step 2. Introducing React into the mix

Check the whole commit.

I don't think I need to tell what React is when it's become a defacto industry standard. What I'm going to tell you is how to setup it to work with electron. Typically at this point, you will want to introduce webpack and spend tons of time configuring it right. But there's a better way, meet the electron-compile package.

yarn add electron-prebuilt-compile --dev

That's kinda it. Now you can use ES2016, SASS, React's JSX, you can use PUG templates for your pages, whatever you like. If it sounds incredible, that's because it is. But the guys behind that package managed to make everything to work so seamlessly. All you need to do is to add a file to the web page and provide a proper type attribute. Now let's add a React entry point to our app:

<!-- src/index.html -->
<!-- ... -->
  <body>
    <div id='root'></div>
    <script src='js/bootstrap.js' type='application/javascript'></script>
  </body>

The src/js/bootstrap.js is going to be our app's entry point.

// src/js/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';

import App from './js/components/app';

ReactDOM.render(
  React.createElement(App),
  document.getElementById('root')
);

Note, when we import a file the root dir in every case is src. All paths are taken relative to it because this is the directory in which our index.html file resides.

And then the src/js/components/app.jsx is the place where we'll define our routes in the next step:

// src/js/components/app.jsx
import React from 'react';

export default () => (
  <div>Hello, React!</div>
);

Update the application and you should see the "Hello, React" text.

Step 3. Creating routes and building the layout

Check the whole commit

Our app is going to have just two routes. The first one is the dashboard, and the second is search page to lookup for new shows. We will use the react-router for that.

yarn add react-router react-router-dom moment

Note, that we also added (moment.js)[https://momentjs.com/]. That's because I'm very bad at dealing with JS dates.

// src/js/app.jsx
import React from 'react';
import { Router, Route } from 'react-router';
import { createHashHistory } from 'history';
import { NavLink } from 'react-router-dom';

import Home from './home';
import Search from './search';

const history = createHashHistory();

export default () => (
  <Router history={history}>
    <div className="window">
      <div className="window-content">
        <div className="pane-group">
          <div className="pane-sm sidebar">
            <nav className="nav-group">
              <h5 className="nav-group-title">Menu</h5>
              <NavLink to='/' className='nav-group-item' exact>
                <span className="icon icon-home"></span>
                Home
              </NavLink>
              <NavLink to='/search' className='nav-group-item' exact>
                <span className="icon icon-search"></span>
                Search
              </NavLink>
              <h5 className="nav-group-title">My Shows</h5>
              { this.state.shows.map(renderShow) }
            </nav>
          </div>
          <div className="pane padded-more">
            <Route path="/" component={Home} exact />
            <Route path="/search" component={Search} exact />
          </div>
        </div>
      </div>
    </div>
  </Router>
);

Wow. Lot's of things going on here. But before that, let's add the Photon styles framework to make our app look more appealing (you may already noticed that we use classes from that library in our layout).

<!-- src/index.html -->
<!-- ... -->
  <head>
    <meta charset="UTF-8">
    <title>ElectroTV</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/photon/0.1.2-alpha/css/photon.min.css" />
  </head>
<!-- ... -->

Let's now create placeholders for home and search components. We will work on them in the next part.

// src/components/home.jsx
import React from 'react';
import moment from 'moment';

export default () => (
  <h1>Today is <span className="firm">{ moment().format("DD of MMMM") }</span>, { moment().format("dddd") }</h1>
);
// src/components/search.jsx
import React from 'react';

export default () => (
  <div>Searh form is going to be here</div>
);

When you run the project, you should be able to navigate between the views using the side menu.

Step 4. Search for a show

Check the whole commit

OK, now we're ready for some real work. What we're going to do is to create a simple form to look for a particular show in thetvdb database, using their public API. Then we can add the show to our local store (which mean simple download and unzip the xml file with the list of show's series).

In order for the API to work, we'll need an API key. You can get it here. Then you should export THETVDB_API_KEY variable, because our app is going to use it.

I'm not going to paster the whole file here (see the commit), but let's take a quick glimpse at how the Api works:

// some imports
import req from 'request';

const API_HOST = "http://thetvdb.com";
const KEY = process.env.THETVDB_API_KEY;

function search(q, cb) {
  const url = `${API_HOST}/api/GetSeries.php?seriesname=${q}`;

  get(url, (res) => {
    cb((res['Data']['Series'] || []).map(i => {
      const out = {
        id: i.id[0],
        name: i.SeriesName[0],
        overview: (i.Overview || [])[0] || 'No Overview'
      };
      if (i.banner) {
        out.banner = `${API_HOST}/banners/${i.banner[0]}`;
      }
      return out;
    }));
  });
}

function get(url, cb) {
  req(url, (err, resp, body) => {
    if (err) throw `Error while req ${url}: ${err}`;
    if (resp.statusCode != 200) throw `Error while req ${url}: code — ${resp.statusCode}`;

    parseString(body, (err, res) => {
      if (err) throw `Error parsing response from ${url}: ${err}`;
      cb(res);
    });
  });
}

Nothing too fancy is going on here. Apart from the fact that we're using the node API (the request module), not the browser's. So we don't have to deal with CORS issues or other related stuff. It's just like server-side code.

Let's add some more npm dependencies:

yarn add xml2js unzip react-addons-css-transition-group

We also added some more styles, but I'm not going to include it here, to not waste space. You can look it up in the commit.

// src/components/search.jsx
import React from 'react';
import _ from 'lodash';

import api from '../api';
import Show from './show';

import ReactCSSTransitionGroup from 'react-addons-css-transition-group';

const Search = React.createClass({

  getInitialState () {
    return {
      loading: false,
      results: []
    };
  },

  changed({ target: { value } }) {
    loadDb(this, value);
  },

  render() {
    return (
      <div>
        <form>
          <div className="form-group">
            <input type="email" className="form-control" placeholder="Search for a TV Show"
              autoFocus onChange={this.changed}></input>
          </div>
        </form>
        <section className='results'>
          {this.state.loading ? Spinner() : renderResults(this, this.state.results) }
        </section>
      </div>
    );
  }
});

function renderResults(vm, res) {
  return (
    <ReactCSSTransitionGroup
      transitionName="fade"
      transitionAppear={true}
      transitionAppearTimeout={300}
      transitionEnterTimeout={500}
      transitionLeaveTimeout={300}>
      { res.map(i => ( <Show show={i} key={i.id} /> )) }
    </ReactCSSTransitionGroup>
  );
}

const loadDb = _.debounce((vm, q) => load(vm, q), 500);

function load(vm, q) {
  vm.setState({ loading: true, results: [] });
  api.search(q, (res = []) => {
    vm.setState({ loading: false, results: res });
  });
}

function Spinner() {
  return (
    <div className="spinner">
      <div className="double-bounce1"></div>
      <div className="double-bounce2"></div>
    </div>
  );
}

export default Search;

We added a simple input field. When the text in it changes we use the api module to search in thetvdb API, and then we put the results into the results instance variable. Note, the invocation of setState. That's the way to tell React that the state has been updated, so the view should be re-rendered. For every show we render a Show component. Let's add it as well

// src/components/show.jsx
import React from 'react';
import store from '../store';

export default class extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      show: this.props.show,
      loading: false
    };
  }

  componentDidMount() {
    const show = this.state.show;
    show.followed = store.isFollowed(this.state.show.id);
    this.setState(Object.assign(this.state, { show }));
  }

  render() {
    const i = this.state.show;
    const ovw = i.overview.length > 128 ? i.overview.substring(0, 128) + "..." : i.overview;
    return (
      <div className="series card">
        <img src={i.banner} />
        <h3>{ i.name }</h3>
        <p> { ovw } </p>
        <div className='action'>
          <button
            className={ cl(this.state.downloading, this.state.show.followed) }
            disabled={this.state.downloading}
            onClick={e => follow(this, i)}>
            <span className="icon icon-star"></span>
            <span className='f1'>FOLLOW</span>
            <span className='f2'>FOLLOWING</span>
            <span className='f3'>UNFOLLOW</span>
          </button>
        </div>
      </div>
    );
  }
}

function cl(downloading, followed) {
  const out = ['btn', 'btn-large', 'btn-primary'];
  downloading && out.push('downloading');
  followed && out.push('followed');
  return out.join(' ');
}

function follow(vm, i) {
  vm.setState({ downloading: true, show: i });
  store.add(i.id, () => {
    vm.setState({ downloading: false, show: i });
  });
}

Notice the line show.followed = store.isFollowed(this.state.show.id). We know that the show is followed if we have the corresponding xml file in out store directory (more on that later), that's what store module is for. The second tim we use it is when we "follow" the show, which means we are downloading the show file from thetvdb database to our local directory.

The store module is responsible for storing and retrieving data from the filesystem. Since thetvdb API gives us the show's data as zipped files, we will store those files in a special dot-directory ~/.eltv. You can find the complete code for store on GitHub, but here's a quick glimpse, the store.add method.

function add(id, cb) {
  if (!fs.existsSync(BASE)) { fs.mkdirSync(BASE); }
  if (!fs.existsSync(BASE_STORE)) { fs.mkdirSync(BASE_STORE); }

  api.download(id, `${BASE_STORE}/${id}`, () => {
    cb();
  });
}

Here we are checking if the local storage directory exists and create it before downloading the file. The magic of Electron is that we can use the fs module here to work with the filesystem.

Run the project now, and you should be able to look up for a show and add it to your shows.

Step 5. Displaying added shows in the sidebar

Check the whole commit

Let's now display the shows we already added in the sidebar. To do that we have to change tha app.jsx component. When the component mounted we will read all shows that we have downloaded.

// src/js/components/app.jsx
import React from 'react';
import { Router, Route } from 'react-router';
import { createHashHistory } from 'history';
import { NavLink } from 'react-router-dom';

import Home from './home';
import Search from './search';

const history = createHashHistory();

export default class extends React.Component {

  constructor() {
    super();

    this.state = {
      shows: []
    };
  }

  componentDidMount() {
    store.readAll((err, shows) => {
      this.setState(Object.assign(this.state, {
        shows: shows.map(show => {
          const s = show.Data.Series[0];
          return { id: s.id[0], name: s.SeriesName[0] };
        })
      }));
    });
  }

  render() {
    return (
      <Router history={history}>
        <div className="window">
          <div className="window-content">
            <div className="pane-group">
              <div className="pane-sm sidebar">
                <nav className="nav-group">
                  <h5 className="nav-group-title">Menu</h5>
                  <NavLink to='/' className='nav-group-item' exact>
                    <span className="icon icon-home"></span>
                    Home
                  </NavLink>
                  <NavLink to='/search' className='nav-group-item' exact>
                    <span className="icon icon-search"></span>
                    Search
                  </NavLink>
                  <h5 className="nav-group-title">My Shows</h5>
                  { this.state.shows.map(renderShow) }
                </nav>
              </div>
              <div className="pane padded-more">
                <Route path="/" component={Home} exact />
                <Route path="/search" component={Search} exact />
              </div>
            </div>
          </div>
        </div>
      </Router>
    );
  }
};

function renderShow({ id, name }) {
  return (
    <NavLink to={`/show/${id}`} key={ id } className='nav-group-item' exact>
      { name }
    </NavLink>
  );
}

Step 6. Home view

Check the whole commit

Finally, we're going to create a home view with several blocks showing new episodes released from the last week and till the current day, coming up later this month, and the next month. For that we're going to read the data from the show's files we downloaded earlier, parse it to JSON and make some basic date comparisons. I'm not going to post the whole file this time since it's huge, but feel free to refer to this commit.

// src/js/components/home.jsx

// ... imports

export default class extends React.Component {
  constructor() {
    super();

    this.state = {
      loading: false,
      groups: []
    };

    this.renderFeed = this.renderFeed.bind(this);
  }

  renderFeed() {
    if (this.state.fetching) {
      return (
        <h3 style={ {textAlign: 'center'} }>Fetching Data From <span className='firm'>thetvdb.com</span>...</h3>
      );
    } else {
      return (this.state.groups || []).map(renderGroup);
    }
  }

  render() {
    return (
      <div className="pane padded-more">
        <h1>Today is <span className="firm">{ moment().format("DD of MMMM") }</span>, { moment().format("dddd") }</h1>
        { (this.state.loading ||  this.state.spinner) ? Spinner() : this.renderFeed() }
      </div>
    );
  }

  fetch(cb) {
    this.setState(Object.assign(this.state, { fetching: true }));
    store.updateAll(() => {
      this.setState(Object.assign(this.state, { fetching: false }));
      cb();
    });
  }

  componentDidMount() {
    this.fetch(() => {
      this.setState({ loading: true, groups: [] });
      store.readAll((err, shows) => {
        shows.forEach(s => {
          const title = s['Data']['Series'][0]['SeriesName'][0];
          s['Data']['Episode'].forEach(e => e.show = title);
        });
        const eps = _.flatten(shows.map(s => s['Data']['Episode']));
        this.setState({ loading: false, groups: group(eps) });
      });
    });
  }
}

Notice, that we doo store.updateAll every time on componentDidMount. That's because we need to make sure that our downloaded files are up-to-date. So we have to re-download them again every time. This logic, of course, could be smarter, but I don't want to complicate it any further.

While the downloading is in progress we put a placeholder saying "Fetching Data From thetvdb.com". After the fetching we reading the files from the filesystem, parsing them and grouping according to the released date.

function group(eps) {
  return [
    {
      title: 'Released Last Week',
      episodes: ready(eps)
    },
    {
      title: 'Coming Up Later This Month',
      episodes: thisMonth(eps)
    },
    {
      title: 'Next Month',
      episodes: nextMonth(eps)
    }
  ];
}

function ready(eps) {
  return withinPeriod(eps, moment().add(-2, 'week'), moment());
}

function thisMonth(eps) {
  return withinPeriod(eps, moment(), moment().endOf('month'));
}

function nextMonth(eps) {
  const start = moment().add(1, 'M').startOf('month');
  const end = moment().add(1, 'M').endOf('month');
  return withinPeriod(eps, start, end);
}

function withinPeriod(eps, start, end) {
  return eps.filter(e => {
    const d = moment(e['FirstAired'][0]);
    return d.isSameOrAfter(start) && d.isBefore(end);
  });
}

And that's mostly it!

Conclusion

Hopefully, by that time you understand the real power of Electron.

By combining the web-dev mentality and the mighty nodejs engine it allows for quick development of rich desktop applications and gives that opportunity to a wider audience of web developers, who don't necessarily know or care about the particular platform's underlying implementational details, nor have to know the OjectiveC, or C#.

We are now able to create desktop apps with only our knowledge of web technologies with the use of the most ubiquitous JavaScript language. That's just pure magic to me.

5 links for diving deeper

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment