5 |
6 |
10 |
53 |
54 |
55 |
56 |
57 |
58 |
Example carousels
59 |
60 |
Just click to enjoy.
61 |
62 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | 'use strict';
4 |
5 | require('dotenv').load({silent: true});
6 |
7 | const fs = require('fs');
8 | if (fs.existsSync(__dirname+'/../.env.json')) {
9 | const environmentOverrides = require(__dirname+'/../.env.json');
10 | process.env = require('lodash').extend(process.env, environmentOverrides);
11 | }
12 |
13 | /**
14 | * Module dependencies.
15 | */
16 |
17 | const app = require('../server/app');
18 | const debug = require('debug')('screens:server');
19 | const http = require('http');
20 |
21 | /**
22 | * Get port from environment and store in Express.
23 | */
24 |
25 | const port = normalizePort(process.env.PORT || '3010');
26 | app.set('port', port);
27 |
28 | /**
29 | * Create HTTP server.
30 | */
31 |
32 | const server = http.createServer(app);
33 |
34 | /**
35 | * Listen on provided port, on all network interfaces.
36 | */
37 |
38 | server.listen(port);
39 |
40 | app.io.attach(server);
41 |
42 | server.on('error', onError);
43 | server.on('listening', onListening);
44 |
45 | /**
46 | * Normalize a port into a number, string, or false.
47 | */
48 |
49 | function normalizePort(val) {
50 | const port = parseInt(val, 10);
51 |
52 | if (isNaN(port)) {
53 | // named pipe
54 | return val;
55 | }
56 |
57 | if (port >= 0) {
58 | // port number
59 | return port;
60 | }
61 |
62 | return false;
63 | }
64 |
65 | /**
66 | * Event listener for HTTP server "error" event.
67 | */
68 |
69 | function onError(error) {
70 | if (error.syscall !== 'listen') {
71 | throw error;
72 | }
73 |
74 | const bind = typeof port === 'string'
75 | ? 'Pipe ' + port
76 | : 'Port ' + port;
77 |
78 | // handle specific listen errors with friendly messages
79 | switch (error.code) {
80 | case 'EACCES':
81 | console.error(bind + ' requires elevated privileges');
82 | process.exit(1);
83 | break;
84 | case 'EADDRINUSE':
85 | console.error(bind + ' is already in use');
86 | process.exit(1);
87 | break;
88 | default:
89 | throw error;
90 | }
91 | }
92 |
93 | /**
94 | * Event listener for HTTP server "listening" event.
95 | */
96 |
97 | function onListening() {
98 | const addr = server.address();
99 | const bind = typeof addr === 'string'
100 | ? 'pipe ' + addr
101 | : 'port ' + addr.port;
102 | console.info('Listening on ' + bind);
103 | }
104 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Screens
2 |
3 | ### Development
4 |
5 | #### Prerequisites
6 | - [NodeJS](nodejs.org)
7 | - [Bower](https://www.npmjs.com/package/bower)
8 | - [Heroku Toolbelt](https://toolbelt.heroku.com/)
9 | - [Java Development Kit](http://www.oracle.com/technetwork/java/javase/downloads/index.html) -- Used for testing.
10 |
11 | #### Setting up development environment
12 | - Clone the repository -- `git clone git@github.com:ftlabs/screens.git`
13 | - Change in repository directory -- `cd screens`
14 | - Install project dependencies -- `bower install && npm install`
15 | - (optional) Set environment variables:
16 | - `AUTH_BACKEND`: "ft-s3o" or "http-basic"
17 | - `AUTH_HTTP_BASIC_NAME`: Username if using HTTP Basic auth
18 | - `AUTH_HTTP_BASIC_PASS`: Password if using HTTP Basic auth
19 | - Start the web server -- `npm start`
20 | - Open the website in your browser of choice -- `open "localhost:3010"`
21 |
22 | #### Tests
23 |
24 | *Warning* Some tests may fail due to flakiness in the integration tests. This makes them unreliable for finding intermittent bugs, e.g. due to the clock or race conditions.
25 |
26 | You may have to run the tests again to get them to pass.
27 |
28 | If they fail a second time run them a third and any consistently failing tests probably indicate an error and will need further investigation.
29 |
30 | The integration tests have been written as robustly as possible but due to complexities in performing web driver tests over two tabs at the same time.
31 |
32 |
33 | #### Deployment to live
34 |
35 | ... having deployed to test, and tested there
36 |
37 | ##### prep
38 |
39 | - announce via #ftlabs that we are updating the Screens system
40 | - open /admin view in a laptop browser tab
41 | - ditto a /viewer view
42 | - open/check the Labs' Intel Compute Stick instance (electron instance)
43 |
44 | ##### deploy!
45 |
46 | - deploy to Heroku (early in the working day so we can spot and pick up any pieces)
47 | - check heroku is happy
48 | - look for reconnects in logs
49 |
50 | ##### check
51 |
52 | - side by side, open up 2nd /admin view, compare
53 | - refresh the /viewer via the new /admin view
54 | - assign new content to the /viewer
55 | - connect a new /viewer via incognito mode, assign content
56 | - check the IntelComputeStick
57 | - refresh the screen via /admin, check
58 | - power off/on the IntelComputeStick, check
59 | - refresh the screen via /admin, check
60 | - take a laptop/smartphone to the nearest lift lobby
61 | - refresh the lobby screen via /admin, check
62 | - power on/off the lobby screen, check
63 | - refresh the lobby screen via /admin, check
64 | - refresh all screens
65 | - check all the lift lobby and entrance screens
66 | - fret
67 |
--------------------------------------------------------------------------------
/views/viewer.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
FT Screens
6 | Please wait
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Screens
15 |
16 |
{{hostname}}/
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
Screen
29 |
This screen is having trouble connecting to the network. Content may not be up to date.
30 |
31 |
32 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | /* global console */
2 | 'use strict'; //eslint-disable-line strict
3 | const gulp = require('gulp');
4 | const obt = require('origami-build-tools');
5 | const spawn = require('child_process').spawn;
6 | let node;
7 |
8 | gulp.task('serve', function() {
9 | if (node) node.kill();
10 | node = spawn('bin/www', [], {stdio: 'inherit'});
11 | console.log('Spawned server as PID '+node.pid);
12 | node.on('exit', function (code, signal) {
13 | console.log('Service exit. '+code+' '+signal);
14 | });
15 | node.on('close', function (code, signal) {
16 | console.log('Service close I/O. '+code+' '+signal);
17 | });
18 | });
19 |
20 | function build(app) {
21 | return obt.build(gulp, {
22 | js: './client/'+app+'/js/main.js',
23 | sass: './client/'+app+'/scss/main.scss',
24 | buildJs: 'bundle.js',
25 | buildCss: 'bundle.css',
26 | buildFolder: 'public/build/'+app
27 | });
28 | }
29 |
30 | gulp.task('buildLogs', function () {
31 | return build('logs');
32 | });
33 |
34 | gulp.task('buildAdmin', function() {
35 | return build('admin');
36 | });
37 |
38 | gulp.task('buildViewer', function() {
39 | return build('viewer');
40 | });
41 |
42 | gulp.task('buildGeneratorLayoutView', function () {
43 | return build('generator-layout-view');
44 | });
45 |
46 | gulp.task('buildGeneratorLayoutAdmin', function () {
47 | return build('generator-layout-admin');
48 | });
49 |
50 | gulp.task('buildGeneratorCarouselView', function () {
51 | return build('generator-carousel-view');
52 | });
53 |
54 | gulp.task('buildGeneratorCarouselAdmin', function () {
55 | return build('generator-carousel-admin');
56 | });
57 |
58 | gulp.task('buildGeneratorRtcView', function () {
59 | return build('generator-rtc-view');
60 | });
61 |
62 | gulp.task('buildGeneratorRtcAdmin', function () {
63 | return build('generator-rtc-admin');
64 | });
65 |
66 | gulp.task('buildGeneratorYoutube', function () {
67 | return build('generator-youtube-player');
68 | });
69 |
70 | gulp.task('buildGenerators', ['buildGeneratorLayoutView', 'buildGeneratorLayoutAdmin', 'buildGeneratorCarouselView', 'buildGeneratorCarouselAdmin', 'buildGeneratorRtcView', 'buildGeneratorRtcAdmin', 'buildGeneratorYoutube']);
71 |
72 | gulp.task('build', ['buildLogs', 'buildAdmin', 'buildViewer', 'buildGenerators']);
73 |
74 | gulp.task('verify', function() {
75 | return obt.verify(gulp, {
76 |
77 | // Files to exclude from Origami verify
78 | excludeFiles: [
79 | '!server/**', // Server side code
80 | '!client/admin/scss/lib/**' //
81 | ]
82 | });
83 | });
84 |
85 | gulp.task('watch', ['build', 'serve'], function() {
86 | gulp.watch('./client/**/*', ['build']);
87 | gulp.watch('./server/**/*', ['serve']);
88 | gulp.watch('./views/**/*', ['serve']);
89 | });
90 |
91 | gulp.task('default', ['verify'], function() {
92 | gulp.run('watch');
93 | });
94 |
--------------------------------------------------------------------------------
/client/viewer/scss/main.scss:
--------------------------------------------------------------------------------
1 | // Output grid helper classes and data-attributes
2 | $o-grid-is-silent: false;
3 |
4 | // Output @font-face declarations
5 | $o-fonts-is-silent: false;
6 |
7 | // Import Origami components
8 | @import 'o-grid/main';
9 | @import 'o-fonts/main';
10 | @import 'o-colors/main';
11 |
12 | // Store the default FT sans-serif font stack in a variable
13 | $sans-serif: oFontsGetFontFamilyWithFallbacks(BentonSans);
14 |
15 | html,
16 | body,
17 | #container {
18 | @include oColorsFor(page, background);
19 | font-family: $sans-serif;
20 | overflow: hidden;
21 | margin: 0;
22 | width: 100%;
23 | height: 100%;
24 | /*
25 | * cursor: none; not supported until Firefox 3, Safari 5, and Chrome 5. Not at all supported in IE or Opera. Image URL cursors not supported in Opera.
26 | * If we want to work everywhere we could use a 1x1 png with an opacity of 1%.
27 | */
28 | cursor: none;
29 | }
30 |
31 | .buffering {
32 | transform: translateX(100%);
33 | }
34 | .active {
35 | transition: transform 0.5s ease;
36 | transform: translateX(0);
37 | }
38 | .done {
39 | transition: transform 0.5s ease;
40 | transform: translateX(-100%);
41 | }
42 | .full {
43 | position: absolute;
44 | top: 0;
45 | left: 0;
46 | width: 100%;
47 | height: 100%;
48 | border: 0;
49 | margin: 0;
50 | padding: 0;
51 | box-sizing: border-box;
52 | }
53 | .panel {
54 | visibility: hidden;
55 | }
56 | .centered {
57 | display: flex;
58 | align-content: center;
59 | align-items: center;
60 | justify-content: center;
61 | flex-direction: column;
62 | > * {
63 | flex: 0 0 auto;
64 | }
65 | }
66 |
67 | .panel-hello {
68 | display: flex;
69 | flex-direction: column;
70 | justify-content: center;
71 | align-items: center;
72 | align-content: center;
73 |
74 | .logo {
75 | flex: 0 0 auto;
76 | display: flex;
77 | flex-direction: row;
78 | align-items: center;
79 | justify-content: center;
80 | align-content: center;
81 | margin: 5vh 0;
82 | color: #eadccc;
83 | svg {
84 | flex: 0 0 18vw;
85 | margin-right: 2vw;
86 | }
87 | span {
88 | font-size: 12vw;
89 | }
90 | }
91 | .url {
92 | font-size: calc(2vw + 12px);
93 | color: #a9957c;
94 | font-weight: 200;
95 | }
96 | }
97 |
98 | .panel-disconnected {
99 | @include oColorsFor(page, background);
100 | max-width: 300px;
101 | bottom: 20px;
102 | right: 20px;
103 | width: 30%;
104 | padding: 20px;
105 | font-size: 80%;
106 | box-shadow: 2px 2px 8px 2px rgba(0, 0, 0, 0.2);
107 | position: fixed;
108 | p {
109 | margin: 0.8em 0;
110 | }
111 | }
112 |
113 | h1,
114 | h3 {
115 | font-weight: 300;
116 | margin: 0.2em 0;
117 | }
118 |
119 | .state-loading .panel-loading,
120 | .state-hello .panel-hello,
121 | .state-active .panel-active,
122 | .state-disconnected .panel-disconnected {
123 | visibility: visible;
124 | }
125 |
126 | #carousel-countdown {
127 | position: fixed;
128 | bottom: 0;
129 | height: 0.5em;
130 | @include oColorsFor(product-brand, background);
131 | width: 100vw;
132 | transform-origin: 0 0;
133 | transform: scaleX(0);
134 | }
--------------------------------------------------------------------------------
/views/admin.handlebars:
--------------------------------------------------------------------------------
1 | {{>header}}
2 |
3 |
FT Screens
4 |
5 |
12 |
13 |
14 |
15 | {{#screens}}
16 | {{>screen}}
17 | {{/screens}}
18 |
19 |
20 |
21 |
22 | Assign content
23 | Clone from Screen
24 | Holding Page
25 |
28 |
29 |
46 |
49 |
64 |
69 |
72 |
73 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/views/generators-markdown-view.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
121 |
122 |
123 |
128 |
129 |
130 |
131 |
132 |
152 |
--------------------------------------------------------------------------------
/views/generators-layout-admin.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
23 |
24 | {{>header}}
25 |
26 |
Layout
27 |
28 |
29 |
30 |
Title (will be displayed on the Control Screens page)
31 |
32 |
33 |
34 |
35 | You can choose any and all of the following panels by specifying a url and a non-zero height in the ones you want to use.
36 | Or, to put it another way, if you don't specify a url and a height, that panel will not be included in the layout.
37 |
38 |
39 |
85 |
86 |
87 | create dashboard
88 |
89 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/server/routes/generators.js:
--------------------------------------------------------------------------------
1 | const router = require('express').Router(); // eslint-disable-line new-cap
2 | const showdown = require('showdown');
3 | const converter = new showdown.Converter();
4 | const cheerio = require('cheerio');
5 | const fetch = require('node-fetch');
6 |
7 | function applyCSP(res) {
8 | res.set('content_security_policy', 'default-src \'none\'; script-src \'self\'; connect-src \'self\'; img-src \'self\'; style-src \'self\';');
9 | }
10 |
11 | // List generators
12 | router.get('/', function(req, res) {
13 | res.render('generators-home', {
14 | app:'admin'
15 | });
16 | });
17 |
18 | // Render a generator
19 |
20 | const auth = require('../middleware/auth/'+(process.env.AUTH_BACKEND || 'ft-s3o'));
21 |
22 | router.route('/').all(auth);
23 | router.get('/layout', function(req, res) {
24 | if (req.query.layout !== undefined) {
25 | applyCSP(res);
26 | res.render('generators-layout-view', {
27 | title: req.query.title || 'Layout'
28 | });
29 | } else {
30 | res.render('generators-layout-admin', {
31 | app:'admin'
32 | });
33 | }
34 | });
35 |
36 | router.get('/carousel', function(req, res) {
37 | if (req.query.u !== undefined || req.query.d !== undefined ) {
38 | applyCSP(res);
39 | res.render('generators-carousel-view', {
40 | title: req.query.title || 'Carousel'
41 | });
42 | } else {
43 | res.render('generators-carousel-admin', {
44 | app:'generator-carousel-admin'
45 | });
46 | }
47 | });
48 |
49 | router.get('/markdown', function(req, res) {
50 | if (req.query.md !== undefined) {
51 | applyCSP(res);
52 | req.query.title = cheerio.load('' + converter.makeHtml(decodeURIComponent(req.query.md)) + '')('body').text();
53 | res.render('generators-markdown-view', req.query);
54 | } else {
55 | res.render('generators-markdown-admin', {
56 | app:'admin'
57 | });
58 | }
59 | });
60 |
61 | router.get('/image', function(req, res) {
62 | applyCSP(res);
63 | res.render('generators-image-viewer', {
64 | title: req.query.title || 'Image'
65 | });
66 | });
67 |
68 |
69 | router.get('/ftvideo', function(req, res) {
70 | if (req.query.id !== undefined) {
71 | applyCSP(res);
72 | fetch('http://next-video.ft.com/'+req.query.id)
73 | .then(function(respStream) {
74 | return respStream.json();
75 | })
76 | .then(function(data) {
77 | const largestRendition = data.renditions.sort(function(a, b) {
78 | return a.frameWidth < b.frameWidth;
79 | })[0];
80 | res.render('generators-ftvideo-viewer', {
81 | title: data.name,
82 | src: largestRendition.url
83 | });
84 | })
85 | ;
86 | } else {
87 | res.send('To use FT video simply assign a video URL to the screen and the generator will be used automatically');
88 | }
89 | });
90 |
91 | router.get('/standby', function(req, res) {
92 | applyCSP(res);
93 | res.render('generators-standby-viewer', {
94 | title: req.query.title
95 | });
96 | });
97 |
98 | router.get('/ticker', function(req, res) {
99 | if (req.query.src !== undefined || req.query.msg !== undefined) {
100 | res.render('generators-ticker-viewer');
101 | } else {
102 | res.render('generators-ticker-admin', {
103 | app:'admin'
104 | });
105 | }
106 | });
107 |
108 | router.get('/rtc', function(req, res) {
109 |
110 | if (req.query.room !== undefined && req.query.id !== undefined) {
111 | res.render('generators-rtc-viewer', {
112 | app: 'generator-rtc-view'
113 | });
114 | } else {
115 | res.render('generators-rtc-creator', {
116 | app: 'generator-rtc-admin'
117 | });
118 | }
119 |
120 | });
121 |
122 | router.get('/empty-screen', function(req, res) {
123 | if (req.query.id !== undefined) {
124 | res.render('generators-id-viewer', {
125 | hostname: req.headers.host,
126 | id: req.query.id
127 | });
128 | }
129 | });
130 |
131 | router.get('/youtube', function(req, res) {
132 | res.render('generators-youtube-player', {
133 | vidID: req.query.mediaURI
134 | });
135 | });
136 |
137 | module.exports = router;
138 |
--------------------------------------------------------------------------------
/client/generator-layout-view/js/main.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | /* global console */
3 | const parseQueryString = require('query-string').parse;
4 |
5 | const params = parseQueryString(window.location.search);
6 |
7 | const heightsAndWidths = /\[(.+)\]/.exec( params.layout )[1].split(',').map(function(v){return parseNonNegativeInt(v);});
8 | const urls = [params.H, params.L, params.R, params.F];
9 |
10 | const transforms = urls.map(function(url){
11 | let promise;
12 |
13 | if (url === '') {
14 | promise = Promise.resolve('');
15 | } else {
16 | const url_to_request_transform = window.location.origin + '/api/transformUrl/' + encodeURIComponent(url);
17 |
18 | promise = fetch(url_to_request_transform)
19 | .then(function(response){
20 | return response.text();
21 | }).then(function(transformed_url){
22 | console.log('transforms: from url=', url, ' to transformed_url=', transformed_url );
23 | return transformed_url;
24 | })
25 | ;
26 | }
27 |
28 | return promise;
29 | });
30 |
31 | Promise.all(transforms)
32 | .then(function(transformedUrls){
33 | const layoutDiv = generateFullLayout(heightsAndWidths, transformedUrls);
34 | document.body.appendChild(layoutDiv);
35 | });
36 |
37 | //------- functions
38 |
39 | function parseNonNegativeInt( val ){
40 | return Math.max( parseInt(val,10) || 0, 0);
41 | }
42 |
43 | function generateFullLayout( heightsAndWidths, urls ) {
44 | const fullWidth = 100;
45 | const fullHeight = 100;
46 |
47 | // all heights and widths should be non -ve ints
48 | let headerHeight = heightsAndWidths[0];
49 | let leftHeight = heightsAndWidths[1];
50 | let leftWidth = heightsAndWidths[2];
51 | let footerHeight = heightsAndWidths[3];
52 |
53 | let rightWidth;
54 |
55 | const headerUrl = urls[0] || '';
56 | const leftUrl = urls[1] || '';
57 | const rightUrl = urls[2] || '';
58 | const footerUrl = urls[3] || '';
59 |
60 | // dont forget to formatUrl these ^^^
61 |
62 | const div = createDiv(fullHeight, fullWidth);
63 |
64 | const sumHeights = headerHeight + leftHeight + footerHeight;
65 |
66 | if(sumHeights > 100) {
67 | headerHeight = Math.trunc(100 * headerHeight / sumHeights);
68 | leftHeight = Math.trunc(100 * leftHeight / sumHeights);
69 | footerHeight = Math.trunc(100 * footerHeight / sumHeights);
70 | }
71 |
72 | if (headerHeight > 0 && headerUrl !== '') {
73 | const hCell = createCell(headerUrl, headerHeight, fullWidth);
74 | div.appendChild( hCell );
75 | }
76 |
77 | if (leftUrl !== '' && leftHeight > 0) {
78 |
79 | if (rightUrl === '') {
80 | leftWidth = 100;
81 | rightWidth = 0;
82 | }
83 |
84 | const sumWidths = leftWidth + rightWidth;
85 | if (sumWidths > 100) {
86 | leftWidth = Math.trunc(100 * leftWidth / sumWidths);
87 | rightWidth = Math.trunc(100 * rightWidth / sumWidths);
88 | } else if (leftWidth < 100) {
89 | rightWidth = 100 - leftWidth;
90 | }
91 |
92 | const leftRightDiv = createDiv(leftHeight, fullWidth);
93 | const leftCell = createCell(leftUrl, fullHeight, leftWidth);
94 | leftRightDiv.appendChild(leftCell);
95 | if (rightUrl !== '' && rightWidth > 0) {
96 | const rightCell = createCell(rightUrl, fullHeight, rightWidth);
97 | leftRightDiv.appendChild(rightCell);
98 | }
99 |
100 | div.appendChild( leftRightDiv );
101 | }
102 |
103 | if (footerHeight > 0 && footerUrl !== '') {
104 | const fCell = createCell(footerUrl, footerHeight, fullWidth);
105 | div.appendChild( fCell );
106 | }
107 |
108 | return div;
109 | }
110 |
111 | function createCell(url, height, width) {
112 | const div = createDiv(height, width);
113 | const iframe = createIframe(url);
114 | div.appendChild(iframe);
115 | return div;
116 | }
117 |
118 | function createDiv(height, width) {
119 | const div = document.createElement('div');
120 | div.style.height = height + '%';
121 | div.style.width = width + '%';
122 | return div;
123 | }
124 |
125 | function createIframe(url) {
126 | const iframe = document.createElement('iframe');
127 | iframe.frameBorder = '0';
128 | iframe.src = url;
129 | iframe.style.height = '100%';
130 | iframe.style.width = '100%';
131 | return iframe;
132 | }
133 |
--------------------------------------------------------------------------------
/server/urls.js:
--------------------------------------------------------------------------------
1 | 'use strict'; //eslint-disable-line strict
2 | const parseQueryString = require('query-string').parse;
3 | const imageType = require('image-type');
4 | const request = require('request');
5 | const debug = require('debug')('screens:server:urls');
6 | const RESPONSE_TIMEOUT = process.env.RESPONSE_TIMEOUT || 1500;
7 |
8 | function isGenerator(url) {
9 | const isGeneratorRegex = /^(https?:\/\/[^\/]*(localhost:\d+|herokuapp.com))?\/generators\/.+/;
10 | return isGeneratorRegex.test(url);
11 | }
12 |
13 | function isYoutube(url) {
14 | const isYoutubeRegex = /^(https?:\/\/)?(www\.)youtube\.com/;
15 | return isYoutubeRegex.test(url);
16 | }
17 |
18 | function isImage(url){
19 |
20 | return new Promise(function(resolve, reject){
21 | request(url, {timeout: RESPONSE_TIMEOUT})
22 | .on('response', function(res){
23 | res.on('end', () => reject('No data in response'));
24 | res.destroy();
25 | })
26 | .on('data', function(chunk) {
27 | const imageMimeType = imageType(chunk) ? imageType(chunk).mime : '';
28 | const isImage = imageMimeType ? imageMimeType.indexOf('image') > -1 : false;
29 | resolve(isImage);
30 | })
31 | .on('error', function(err){
32 | if (err.code === 'ETIMEDOUT') {
33 | debug(`Timed-out requesting ${url}`);
34 | }
35 | reject(err);
36 | })
37 | ;
38 |
39 | });
40 |
41 | }
42 |
43 | function isSupportedByImageService(url){
44 | const isAnImageRegex =/\.(jpg|jpeg|tiff|png)$/i;
45 | return isAnImageRegex.test(url);
46 | }
47 |
48 | function isFTVideo(url) {
49 | const isFTVidRegex = /^(https?:\/\/)?video\.ft\.com\/(\d{7,})(\/.*)?$/;
50 | return isFTVidRegex.test(url);
51 | }
52 |
53 | function transformYoutubeURL(queryParams, host){
54 |
55 | const resourceURI = queryParams.list || queryParams.v;
56 | const mediaType = (queryParams.list) ? 'playlist' : 'video';
57 |
58 | return 'http://' + host + '/generators/youtube?mediaURI=' + resourceURI + '&mediaType=' + mediaType;
59 | }
60 |
61 | function transformImageWithImageService(url, host) {
62 | const title = url.match(/[^/]+$/)[0];
63 | return 'http://' + host + '/generators/image/?' + encodeURIComponent('https://www.ft.com/__origami/service/image/v2/images/raw/' + encodeURIComponent(url) + '?source=screens') + '&title=' + title;
64 | }
65 |
66 | function transformImage(url, host) {
67 | const title = url.match(/[^/]+$/)[0];
68 | return 'http://' + host + '/generators/image/?' + encodeURIComponent(url) + '&title=' + title;
69 | }
70 |
71 | function tranformFTVideo(url, host) {
72 | const id = url.match(/\.com\/(\d{7,})/)[1];
73 | return 'http://' + host + '/generators/ftvideo/?id=' + id;
74 | }
75 |
76 | module.exports = function transform (url, host) {
77 | let promise;
78 |
79 | if (isGenerator(url)) {
80 | console.log('transform: isGenerator, url=', url);
81 | promise = Promise.resolve(url);
82 | } else if (isYoutube(url)) {
83 | console.log('transform: isYoutube, url=', url);
84 | const queryParams = parseQueryString(url.split('?')[1]);
85 |
86 | if (queryParams.list || queryParams.v) {
87 | console.log('transform: isYoutube, url=', url);
88 | promise = Promise.resolve(transformYoutubeURL(queryParams, host));
89 | } else {
90 | console.log('transform: isYoutube but not valid, url=', url);
91 | promise = Promise.resolve(url);
92 | }
93 | } else if (isFTVideo(url)){
94 | console.log('transform: isFTVideo, url=', url);
95 | promise = Promise.resolve(tranformFTVideo(url, host));
96 | } else {
97 | console.log('transform: unknown so checking isImage, url=', url);
98 | promise = isImage(url)
99 | .then(function(isImage){
100 | if(isImage){
101 | if (isSupportedByImageService(url)) {
102 | console.log('transform: isImage.isSupportedByImageService, url=', url);
103 | url = transformImageWithImageService(url, host);
104 | } else {
105 | console.log('transform: isImage not.isSupportedByImageService, url=', url);
106 | url = transformImage(url, host);
107 | }
108 | } else {
109 | console.log('transform: not isImage, url=', url);
110 | }
111 |
112 | return url;
113 | })
114 | .catch(err => {
115 | debug(err);
116 | return url;
117 | })
118 | ;
119 | }
120 |
121 | return promise;
122 | };
123 |
--------------------------------------------------------------------------------
/server/log.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /**
3 | * redis.js
4 | *
5 | * Used to store Audit logs.
6 | */
7 |
8 | const Redis = require("redis");
9 | const debug = require('debug')('screens:log');
10 |
11 | const screens = require('./screens');
12 |
13 | const MAX_LOG_LENGTH = process.env.REDIS_LOG_LENGTH || 5000;
14 | const LOG_KEY = process.env.REDIS_LOG_KEY || 'FTLABS_SCREENS_LOG';
15 | const VIEW_LIST_LENGTH = process.env.VIEW_LIST_LENGTH || 500;
16 |
17 | const eventTypes = {
18 | screenDisconnected: { id: 0, longDesc: 'Viewer Disconnected' },
19 | screenConnected: { id: 1, longDesc: 'Viewer Connected' },
20 | screenReloaded: { id: 2, longDesc: 'Viewer Reloaded' },
21 | screenRenamed: { id: 4, longDesc: 'Viewer Renamed' },
22 | screenContentAssignment: { id: 8, longDesc: 'New content has been added to the screen' },
23 | screenContentRemoval: { id: 16, longDesc: 'Content has been removed from the screen' },
24 | screenContentCleared: { id: 32, longDesc: 'All content has been cleared from the screen' },
25 | allScreensReloaded: { id: 64, longDesc: 'All viewers were reloaded' }
26 | };
27 | let redis;
28 |
29 | module.exports = {
30 | eventTypes, // Object
31 | logApi, // Function
32 | logConnect, // Function
33 | renderView // Function
34 | };
35 |
36 | if (process.env.REDISTOGO_URL || process.env.REDIS_PORT) {
37 | const rtg = require('url').parse(process.env.REDISTOGO_URL || process.env.REDIS_PORT);
38 | redis = Redis.createClient(rtg.port, rtg.hostname);
39 | if (rtg.auth) redis.auth(rtg.auth.split(":")[1]);
40 | } else {
41 | redis = Redis.createClient();
42 | }
43 |
44 | function getTypeDescription(options) {
45 | const eventType = options.eventType;
46 | const screenId = options.screenId;
47 | const username = options.username;
48 |
49 | const event = Object.keys(eventTypes)
50 | .map(k => eventTypes[k])
51 | .filter(event => event.id === eventType)[0];
52 |
53 | let longDesc;
54 |
55 | if (!event) {
56 | longDesc = `No Description for request with eventType ${eventType}`;
57 | } else {
58 | longDesc = event.longDesc;
59 | }
60 |
61 |
62 | if (screenId) {
63 | longDesc = longDesc + `, on screen ${screenId}`;
64 | const data = screens.get(screenId);
65 | if (data && data.name) {
66 | longDesc += ` (${data.name})`;
67 | }
68 | }
69 |
70 | if (username) {
71 | longDesc = longDesc + `, by '${username}'`;
72 | }
73 |
74 | return longDesc;
75 | }
76 |
77 |
78 | function getMessageWrapper(options) {
79 | const eventType = options.eventType;
80 | const screenId = options.screenId;
81 | const username = options.username;
82 |
83 | return {
84 | timestamp: Date.now(),
85 | eventType,
86 | eventDesc: getTypeDescription({eventType, screenId, username}),
87 | screenId,
88 | username,
89 | details: {}
90 | };
91 | }
92 |
93 | function handleConnectErr(err) {
94 |
95 | // prevents redis fron dying if an error happens
96 | // will try to reconnect.
97 | debug(err.message);
98 | }
99 |
100 | redis.addListener("error", handleConnectErr);
101 |
102 | function pushMessageAndTrimList(messageStr) {
103 | if (redis) {
104 | // as recommended in http://redis.io/commands/LTRIM
105 | redis.lpush(LOG_KEY, messageStr);
106 | redis.ltrim(LOG_KEY, 0, MAX_LOG_LENGTH - 1);
107 | }
108 | }
109 |
110 | function logApi(options) {
111 | const eventType = options.eventType;
112 | const screenId = options.screenId;
113 | const username = options.username;
114 | const details = options.details;
115 | const message = getMessageWrapper({
116 | eventType,
117 | screenId,
118 | username
119 | });
120 | message.details = details;
121 | pushMessageAndTrimList( JSON.stringify(message) );
122 | debug(message.eventDesc);
123 | }
124 |
125 | function logConnect(options) {
126 | const eventType = options.eventType;
127 | const screenId = options.screenId;
128 | const details = options.details;
129 | const message = getMessageWrapper({eventType, screenId});
130 | message.details = details;
131 | pushMessageAndTrimList( JSON.stringify(message) );
132 | debug(message.eventDesc);
133 | }
134 |
135 | function renderView(req, res) {
136 | redis.lrange(LOG_KEY, 0, VIEW_LIST_LENGTH -1, function (error, logEntries) {
137 |
138 | if (error) {
139 | debug(error);
140 | return res.render('error', {error, app: 'admin'});
141 | }
142 |
143 | const logs = logEntries.map(JSON.parse);
144 |
145 | logs.forEach(log => {
146 |
147 | // Don't use the stored one in case we update the descriptions.
148 | log.eventDesc = getTypeDescription(log);
149 | });
150 |
151 | res.render('logs', {
152 | logs,
153 | app: 'logs'
154 | });
155 | });
156 | }
157 |
--------------------------------------------------------------------------------
/public/index_old.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Signage Client
7 |
8 |
9 |
14 |
23 |
24 |
100 |
101 |
105 |
106 |
107 |
116 |
117 |
118 |
121 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
Loading...
139 |
140 |
141 |
142 |
143 |
To put something on this screen, go to ftlabs-screens.herokuapp.com and enter code
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
Screen
22232
153 |
This screen is having trouble connecting to the network. Content may not be up to date.
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
--------------------------------------------------------------------------------
/views/layouts/main.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{title}}{{^title}}FT Screens{{/title}}
5 |
6 |
7 |
8 |
9 |
10 | {{#redirect}}
11 |
66 | {{/redirect}}
67 | {{^redirect}}
68 |
72 |
76 |
77 | {{#if app}}
80 |
81 | {{/if}}
82 |
83 |
92 |
93 |
94 |
97 |
107 | {{/redirect}}
108 |
109 |
110 | {{^redirect}}
111 | {{{body}}}
112 |
113 |
116 |
149 | {{/redirect}}
150 |
151 |
152 |
--------------------------------------------------------------------------------
/views/generators-markdown-admin.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
126 |
127 |
128 | {{>header}}
129 |
134 |
135 |
136 |
137 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
221 |
--------------------------------------------------------------------------------
/client/admin/scss/main.scss:
--------------------------------------------------------------------------------
1 | @import "../../common/scss/admin-common";
2 |
3 | $fa-font-path: "/fonts";
4 | @import './lib/font-awesome/font-awesome';
5 |
6 | select {
7 | @include oFormsCommonField;
8 | @include oFormsSelect;
9 | width: auto;
10 | }
11 |
12 | .input-text {
13 | @include oFormsCommonField;
14 | width: auto;
15 | }
16 |
17 | label {
18 | user-select: none;
19 | }
20 |
21 | button {
22 | @include oButtons(big, standout);
23 | }
24 |
25 | .filterbar {
26 | display: flex;
27 | align-items: center;
28 | }
29 | .filterbar__selectall {
30 | padding-left: 5px;
31 | flex: 0.6 1 auto;
32 | }
33 | .filterbar__text {
34 | @include oFormsCommonField;
35 | width: auto;
36 | max-width: 500px;
37 | flex: 0.4 1 auto;
38 | }
39 |
40 | .highlight {
41 | font-weight: bold;
42 | background-color: rgba(159, 36, 255, 0.3);
43 | }
44 |
45 | .screens {
46 | border-collapse: collapse;
47 | width: 100%;
48 | border-bottom: 1px solid #bbbbbb;
49 | margin: 5px 0;
50 | td,
51 | th {
52 | border-top: 1px solid #bbbbbb;
53 | padding: 5px;
54 | position: relative;
55 | }
56 |
57 | .screen-name-col {
58 | max-width: 14em;
59 | min-width: 8em;
60 | }
61 |
62 | @include oGridRespondTo(M) {
63 |
64 | // Add some extra space for the floating action baron large screen
65 | margin-bottom: 8em;
66 | }
67 | }
68 |
69 | .rename-group {
70 | display: none;
71 | .action-rename {
72 | display: none;
73 | }
74 | }
75 |
76 | .rename-mode {
77 | .rename-group {
78 | display: block;
79 | position: absolute;
80 | top: 0;
81 | left: 0;
82 | }
83 |
84 | .screen-name {
85 | visibility: hidden;
86 | }
87 |
88 | .action-rename {
89 | display: none;
90 | }
91 | }
92 |
93 | .screen-id-col {
94 | width: 3em;
95 | }
96 |
97 | .screen-name-col:before {
98 | padding: 0 1em;
99 | .rename-mode & {
100 | content: "";
101 | }
102 | }
103 | .action-rename {
104 | visibility: hidden;
105 | cursor: pointer;
106 | }
107 | .screen:hover .action-rename {
108 | visibility: visible;
109 | cursor: pointer;
110 | }
111 |
112 | .screen-offline,
113 | .screen-remove {
114 | opacity: 0.5;
115 | }
116 |
117 | .queue {
118 | list-style: none;
119 | padding: 0;
120 | margin: 0;
121 | font-size: 13px;
122 | }
123 |
124 | .queue li {
125 | margin-bottom: 4px;
126 | overflow: hidden;
127 | display: flex;
128 |
129 | .item-info-url,
130 | .item-info-date {
131 | flex: 2 0;
132 | line-height: 1em;
133 | max-height: 3em;
134 | }
135 |
136 | .item-info-url {
137 | flex: 4 1;
138 | text-align: right;
139 | }
140 |
141 | .item-info-remove{
142 | margin: 0 8px;
143 | }
144 |
145 | .item-info-expires {
146 | padding-right: 1em;
147 | }
148 |
149 | .active {
150 | font-weight: bold;
151 | }
152 |
153 | a {
154 | max-width: 100%;
155 | max-height: 3em;
156 | overflow: hidden;
157 | display: inline-block;
158 | p {
159 | margin: 0;
160 | overflow: hidden;
161 | position: relative;
162 | text-overflow: ellipsis;
163 | white-space: nowrap;
164 | max-width: 50vw;
165 | }
166 |
167 | &[data-troublesome-url="true"]{
168 | background: #DA6A6A;
169 | color: white;
170 | padding: 3px 0;
171 | text-decoration: none;
172 | }
173 |
174 | &[data-troublesome-url="true"]:hover{
175 |
176 | &:after{
177 | content: "This content may not show properly on some screens";
178 | position: absolute;
179 | font-weight: 200;
180 | background-color: inherit;
181 | color: white;
182 | padding: 3px;
183 | margin-top: -1.2em;
184 | }
185 |
186 | }
187 |
188 | }
189 |
190 | }
191 |
192 | .generator-select {
193 | a {
194 | text-decoration: none;
195 |
196 | p {
197 | margin: 0;
198 | }
199 | }
200 | margin: 1em 0 0;
201 | list-style: none;
202 | }
203 | h1,
204 | h2,
205 | h3,
206 | h4,
207 | h5,
208 | h6 {
209 | font-weight: 300;
210 | }
211 | .actions {
212 | display: flex;
213 | flex-direction: column;
214 | margin: 0 -0.25em 0.5em;
215 |
216 | @include oGridRespondTo(M) {
217 | flex-direction: row;
218 |
219 | // On large screen float the action bar to give it easy access,
220 | // not on small because it takes up a lot screen real estate
221 | position: fixed;
222 | box-shadow: 0 0 1em rgba(0,0,0,0.5);
223 | background: #fff1e0;
224 | left: 0;
225 | bottom: 0;
226 | right: 0;
227 | padding: 0.5em;
228 | margin: 0;
229 | }
230 |
231 | .action-form-group {
232 | display: flex;
233 | flex: 1 0;
234 | min-width: 85%;
235 | }
236 |
237 | input,
238 | select,
239 | button {
240 | margin: 0 0.25em 0.5em;
241 | min-width: 8em;
242 | }
243 |
244 | .action-options {
245 | display: none;
246 | }
247 |
248 | .action-remove {
249 | cursor: pointer;
250 | }
251 |
252 | .action-options[aria-selected=true] {
253 | flex: 1;
254 | display: flex;
255 | flex-flow: row wrap;
256 |
257 | #selurlduration,
258 | #selholdduration {
259 | flex: 1 0;
260 | }
261 |
262 | #txturl,
263 | #selscreen {
264 | flex: 1;
265 | min-width: 85%;
266 | @include oGridRespondTo(M) {
267 | min-width: 200px;
268 | }
269 | }
270 | }
271 | }
272 |
--------------------------------------------------------------------------------
/server/app.js:
--------------------------------------------------------------------------------
1 | /* global __dirname */
2 | 'use strict';
3 |
4 | const express = require('express');
5 | const path = require('path');
6 | const favicon = require('serve-favicon');
7 | const morganLogger = require('morgan');
8 | const cookieParser = require('cookie-parser');
9 | const bodyParser = require('body-parser');
10 | const exphbs = require('express-handlebars');
11 | const moment = require('moment');
12 | const debug = require('debug')('screens:app');
13 | const cookie = require('cookie');
14 | const pages = require('./pages');
15 | const screens = require('./screens');
16 | const log = require('./log');
17 | const ftwebservice = require('express-ftwebservice');
18 | const sentry = require('./sentry');
19 | const app = express();
20 |
21 | // The request handler must be the first item
22 | app.use(sentry.requestHandler);
23 |
24 | // The error handler must be before any other error middleware
25 | app.use(sentry.errorHandler);
26 |
27 | // Create Socket.io instance
28 | app.io = require('socket.io')();
29 | screens.setApp(app);
30 |
31 | // Use Handlebars for templating
32 | const hbs = exphbs.create({
33 | defaultLayout: 'main',
34 | helpers: {
35 | ifEq: function(a, b, options) { return (a === b) ? options.fn(this) : options.inverse(this); },
36 | join: function(arr) { return [].concat(arr).join(', '); },
37 | htmltitle: function(url) { return pages(url).getTitle() || url; },
38 | revEach: function(context, options) { return context.reduceRight(function(acc, item) { acc += options.fn(item); return acc; }, ''); },
39 | relTime: function(time) { return moment(time).fromNow(); },
40 | toLower: function(str) { return String(str).toLowerCase(); },
41 | }
42 | });
43 | app.engine('handlebars', hbs.engine);
44 | app.set('view engine', 'handlebars');
45 | app.hbs = hbs;
46 |
47 | // Write HTTP request log using Morgan
48 | app.use(morganLogger('dev'));
49 |
50 | // Serve static files
51 | app.use(favicon(path.join(__dirname, '../public/favicon.ico')));
52 | app.use(express.static(path.join(__dirname, '../public')));
53 | app.use('/bower_components', express.static(path.join(__dirname, '../bower_components')));
54 |
55 | // /__gtg, /__health, and /__about.
56 | ftwebservice(app, {
57 | manifestPath: path.join(__dirname, '../package.json'),
58 | about: require('../runbook.json'),
59 | healthCheck: require('../tests/healthcheck'),
60 |
61 | // TODO AE07122015: Once logging is merged check that the database can be connected to
62 | goodToGoTest: () => Promise.resolve(true)
63 | });
64 |
65 | // Parse requests for body content and cookies
66 | app.use(bodyParser.json());
67 | app.use(bodyParser.urlencoded({ extended: false }));
68 | app.use(cookieParser());
69 |
70 | // Serve routes
71 | app.use('/', require('./routes/index'));
72 | app.use('/api', require('./routes/api'));
73 | app.use('/admin', require('./routes/admin'));
74 | app.use('/viewer', require('./routes/viewer'));
75 | app.use('/generators', require('./routes/generators'));
76 | app.use('/logs', log.renderView);
77 |
78 | app.all('*', function(req, res, next) {
79 | res.set('Access-Control-Allow-Origin', '*');
80 | res.set('Access-Control-Allow-Methods', 'GET, POST');
81 | res.set('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type');
82 | res.set('Strict-Transport-Security', 'max-age=0;');
83 | next();
84 | });
85 |
86 | const previouslySeenScreens = {};
87 |
88 | // Serve websocket connections
89 | app.io.on('connection', function(socket) {
90 |
91 | debug(socket);
92 |
93 | if(socket.handshake.headers.cookie !== undefined){
94 |
95 | const cookies = cookie.parse(socket.handshake.headers.cookie);
96 |
97 | if (cookies.electrondata !== null && cookies.electrondata !== undefined) {
98 | const id = JSON.parse(cookies.electrondata).id;
99 |
100 | debug(id);
101 | if (id in previouslySeenScreens) {
102 | debug('seen', id);
103 | } else {
104 | debug('not seen', id, 'reloading screen');
105 | socket.emit('reload');
106 | previouslySeenScreens[id] = true;
107 | }
108 | }
109 |
110 | }
111 |
112 | });
113 |
114 | app.io.of('/screens').on('connection', function(socket) {
115 | screens.add(socket);
116 | socket.emit('heartbeat');
117 | debug('connection started');
118 | socket.on('heartbeat',function() {
119 | socket.emit('heartbeat');
120 | });
121 | });
122 |
123 | app.io.of('/admins').on('connection', function(socket) {
124 | screens.generateAdminUpdate().then(function(updates) {
125 | socket.emit('allScreensData', updates);
126 | });
127 | });
128 |
129 | // Catch anything not served by a defined route and return a 404
130 | app.use(function(req, res, next) {
131 | const err = new Error('Not Found');
132 | err.status = 404;
133 | next(err);
134 | });
135 |
136 | // Error handlers
137 |
138 | // development error handler
139 | // will print stacktrace
140 | if (app.get('env') === 'development') {
141 | app.use(function(err, req, res) {
142 | res.status(err.status || 500);
143 | res.render('error', {
144 | message: err.message,
145 | error: err,
146 | app:'logs'
147 | });
148 | });
149 | }
150 |
151 | // production error handler
152 | // no stacktraces leaked to user
153 | app.use(function(err, req, res) {
154 | res.status(err.status || 500);
155 | res.render('error', {
156 | message: err.message,
157 | error: {},
158 | app:'logs'
159 | });
160 | });
161 |
162 | module.exports = app;
163 |
--------------------------------------------------------------------------------
/client/generator-youtube-player/js/main.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | /* global YT, console */
3 | const YTG = (function(){
4 |
5 | let player;
6 | let bufferingTO;
7 | const coverCard = document.getElementById('cover_card');
8 | const mediaURI = window.location.href.split('mediaURI=')[1].split('&')[0];
9 | const mediaType = window.location.href.split('mediaType=')[1].split('&')[0];
10 |
11 | const playerStates = {
12 | '-1' : 'unstarted',
13 | '0' : 'ended',
14 | '1' : 'playing',
15 | '2' : 'paused',
16 | '3' : 'buffering',
17 | '5' : 'cued'
18 | };
19 |
20 | const playerOptions = {
21 | width: window.innerWidth,
22 | height: window.innerHeight,
23 | events: {
24 | 'onReady': playerReady,
25 | 'onStateChange': playerStateChange,
26 | 'onError' : playerError
27 | },
28 | playerconsts : {
29 | controls : 0,
30 | modestbranding : 1
31 | }
32 | };
33 |
34 | function showCoverCard(){
35 | coverCard.setAttribute('data-visible', 'true');
36 | }
37 |
38 | function hideCoverCard(){
39 | coverCard.setAttribute('data-visible', 'false');
40 | }
41 |
42 | function checkNetworkState(){
43 |
44 | return new Promise(function(resolve, reject){
45 |
46 | const nR = new XMLHttpRequest();
47 |
48 | nR.onload = function(){
49 |
50 | if(nR.status === 200){
51 | resolve('A-OK');
52 | } else {
53 | reject('The network test endpoint returned a status code other than 200');
54 | }
55 |
56 | };
57 |
58 | nR.ontimeout = function(){
59 | reject('The test request timed out');
60 | };
61 |
62 | nR.onerror = function(){
63 | reject('There was an error when testing the connectivity of the network');
64 | };
65 |
66 | nR.timeout = 8000;
67 | nR.open('GET', window.location.origin + '/viewer');
68 | nR.send();
69 |
70 | });
71 |
72 | }
73 |
74 | function destroyPlayer(){
75 | console.log('PLAYER DESTROYED');
76 | player.destroy();
77 | }
78 |
79 | function createPlayer(){
80 | console.log('PLAYER CREATED');
81 | player = new YT.Player('yt-player', playerOptions);
82 | }
83 |
84 | function onYouTubeIframeAPIReady() {
85 | createPlayer();
86 | }
87 |
88 | function playerReady() {
89 |
90 | let playListOptions;
91 |
92 | if(mediaType === 'playlist'){
93 | // This is a playlist URI.
94 | playListOptions = {
95 | list : mediaURI
96 | };
97 | } else if(mediaType === 'video') {
98 | // This is a single video URI
99 | playListOptions = {
100 | playlist : mediaURI
101 | };
102 | }
103 |
104 | player.loadPlaylist(playListOptions);
105 | player.setLoop(true);
106 |
107 | }
108 |
109 | function playerStateChange(evt){
110 |
111 | console.log(playerStates[evt.data]);
112 |
113 | if(playerStates[evt.data] === 'playing'){
114 | hideCoverCard();
115 | console.log('Now playing: %s', player.getVideoData().title);
116 | } else if(playerStates[evt.data] === 'buffering'){
117 |
118 | clearTimeout(bufferingTO);
119 | bufferingTO = undefined;
120 |
121 | bufferingTO = setTimeout(function(){
122 |
123 | if(playerStates[player.getPlayerState()] === 'buffering'){
124 |
125 | handleNetworkIssues();
126 |
127 | }
128 |
129 | }, 10000);
130 |
131 | } else if(bufferingTO !== undefined){
132 | clearTimeout(bufferingTO);
133 | bufferingTO = undefined;
134 | }
135 |
136 | }
137 |
138 | function handleNetworkIssues(){
139 |
140 | console.log('Handling network issues');
141 |
142 | checkNetworkState()
143 | .then(function(networkStatus){
144 | console.log(networkStatus);
145 |
146 | destroyPlayer();
147 | createPlayer();
148 |
149 | })
150 | .catch(function(err){
151 | console.error(err);
152 | // Fail gracefully - Check network periodically
153 | showCoverCard();
154 | setTimeout(function(){
155 |
156 | handleNetworkIssues();
157 |
158 | }, 20000);
159 |
160 | })
161 | ;
162 |
163 | }
164 |
165 | function playerError(error){
166 |
167 | const errCode = error.data;
168 |
169 | const errors = {
170 | '-1' : {
171 | shortReason : 'unstarted',
172 | longReason : 'Something has gone wrong with the player. It likely can\'t access the video resource on the network'
173 | },
174 | '2' : {
175 | shortReason : 'invalidparameter',
176 | longReason : 'The request contains an invalid parameter value.'
177 | },
178 | '5' : {
179 | shortReason : 'html-error',
180 | longReason : 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.'
181 | },
182 | '100' : {
183 | shortReason : 'no-video',
184 | longReason : 'This error occurs when a video has been removed (for any reason) or has been marked as private.'
185 | },
186 | '101' : {
187 | shortReason : 'no-embed',
188 | longReason : 'The owner of the requested video does not allow it to be played in embedded players.'
189 | },
190 | '105' : {
191 | shortReason : 'no-embed',
192 | longReason : 'The owner of the requested video does not allow it to be played in embedded players.'
193 | }
194 |
195 | };
196 |
197 | if (errors[errCode].shortReason === 'html-error' || errors[errCode].shortReason === 'unstarted'){
198 |
199 | console.log(errors[errCode].longReason);
200 |
201 | //Check network status, if connected -> restart - if not -> handle gracefully, alert admin
202 |
203 | showCoverCard();
204 | handleNetworkIssues();
205 |
206 | }
207 |
208 | }
209 |
210 | document.title = 'FT Screens || Youtube Generator';
211 |
212 | return {
213 | onYouTubeIframeAPIReady : onYouTubeIframeAPIReady
214 | };
215 |
216 | }());
217 |
218 | window.onYouTubeIframeAPIReady = YTG.onYouTubeIframeAPIReady;
219 |
--------------------------------------------------------------------------------
/server/screens.js:
--------------------------------------------------------------------------------
1 | 'use strict'; //eslint-disable-line strict
2 |
3 | const extend = require('lodash').extend;
4 | const debug = require('debug')('screens:screens');
5 | const logs = require('./log');
6 | const assignedIDs = new Map();
7 | const _ = require('lodash');
8 |
9 | let app;
10 |
11 | function socketsForIDs(ids) {
12 | const clients = app.io.of('/screens').connected;
13 | return Object.keys(clients).filter(function(sockID) {
14 | return (clients[sockID].data && (!ids || !ids.length || ids.indexOf(clients[sockID].data.id) !== -1));
15 | }).map(function(sockID) {
16 | return clients[sockID];
17 | });
18 | }
19 |
20 | function syncDown(sock) {
21 | sock.emit('update', sock.data);
22 |
23 | updateAdmins(sock);
24 | }
25 |
26 | function updateAdmins(sock) {
27 | // Tell all admin users about the update
28 | generateAdminUpdate(sock).then(function(data) {
29 | app.io.of('/admins').emit('screenData', data);
30 | });
31 | }
32 |
33 | function generateAdminUpdate(sock) {
34 | return app.hbs.render('views/screen.handlebars', sock.data).then(function(content) {
35 | return {
36 | id: sock.data.id,
37 | content: content
38 | };
39 | });
40 | }
41 |
42 | function decideWhichScreenGetsToKeepAnID(screenA, screenB){
43 |
44 |
45 | // Emit an event to the screens to reassign using the ID and the timestamp
46 | // of when that id was assigned as the identifier for the screen that needs
47 | // to reassign. The original screen (and the rest) can ignore this message.
48 | // screens without idUpdated assigned count as being older than the ones
49 | // with, this is so that old clients which don't support reassign won't
50 | // be expected to change.
51 | const screenToChange = (screenA.idUpdated || 0) > (screenB.idUpdated || 0) ? screenA : screenB ;
52 |
53 | app.io.of('/screens').emit('reassign', {
54 | id : screenToChange.id,
55 | idUpdated : screenToChange.idUpdated,
56 | newID : generateID()
57 | });
58 |
59 | }
60 |
61 |
62 | function checkForConflictingId(id){
63 |
64 | return assignedIDs.has(id);
65 |
66 | }
67 |
68 | function checkForConflictingScreens(data){
69 |
70 | if (assignedIDs.has(data.id)) {
71 | const existingScreen = assignedIDs.get(data.id);
72 | if (existingScreen.idUpdated !== data.idUpdated) {
73 | return true;
74 | }
75 | }
76 | return false;
77 | }
78 |
79 | function generateID(){
80 |
81 | let newID = parseInt(Math.random() * 99999 | 0, 10);
82 |
83 | while(checkForConflictingId(newID) === true){
84 | newID = parseInt(Math.random() * 99999 | 0, 10);
85 | }
86 |
87 | return newID;
88 | }
89 |
90 | module.exports.setApp = function(_app) {
91 | app = _app;
92 | };
93 |
94 | module.exports.add = function(socket) {
95 |
96 | // Store metadata against the socket.
97 | // While we're using the socket list as a data store, all sockets are
98 | // considered online, because we'll forget about them as soon as they disconnect.
99 | socket.data = {
100 | id: null,
101 | items: []
102 | };
103 |
104 | debug(`New screen connected on socket ${socket.id}`);
105 |
106 | // Request registration on connect so that registration is done on reconnects as well as the initial connect
107 | socket.emit('requestUpdate');
108 |
109 | socket.on('update', function(data) {
110 | const newData = _.cloneDeep(data);
111 | // If screen has not cited a specific ID, assign one
112 | if (!newData.id || !parseInt(newData.id, 10)) {
113 | newData.id = generateID();
114 | }
115 |
116 | const thereIsAConflict = checkForConflictingScreens(newData);
117 |
118 | if (thereIsAConflict) {
119 | decideWhichScreenGetsToKeepAnID(newData, assignedIDs.get(newData.id) );
120 | return;
121 | } else {
122 | // Only save the screen as existing if it is using the new api
123 | if (newData.id && newData.idUpdated) {
124 | assignedIDs.set(newData.id, {id : newData.id, idUpdated : newData.idUpdated});
125 | }
126 | }
127 |
128 | if (!socket.data.id) {
129 | debug('New screen on socket '+socket.id+' now identifies as '+newData.id+' ('+newData.name+')');
130 | }
131 |
132 | logs.logConnect({
133 | eventType: logs.eventTypes.screenConnected.id,
134 | screenId: newData.id,
135 | details: {
136 | name: newData.name,
137 | }
138 | });
139 |
140 | // Record the updated newData against the socket
141 | extend(socket.data, newData);
142 |
143 | if (!_.isEqual(data, newData)) {
144 | syncDown(socket);
145 | } else {
146 | updateAdmins(socket);
147 | }
148 |
149 | });
150 |
151 | socket.on('disconnect', function() {
152 | debug('Screen disconnected: '+this.data.id+ ' from socket '+this.id);
153 | logs.logConnect({
154 | eventType: logs.eventTypes.screenDisconnected.id,
155 | screenId: this.data.id,
156 | details: {
157 | name: this.data.name,
158 | }
159 | });
160 |
161 | app.io.of('/admins').emit('screenData', { id: this.data.id });
162 | });
163 | };
164 |
165 | module.exports.set = function(ids, data) {
166 | data = data || {};
167 | socketsForIDs(ids).forEach(function(sock) {
168 | extend(sock.data, data);
169 | syncDown(sock);
170 | });
171 | };
172 |
173 | module.exports.get = function(ids) {
174 | return socketsForIDs(ids).map(function(sock) {
175 | return sock.data;
176 | });
177 | };
178 |
179 | module.exports.pushItem = function(ids, item) {
180 | socketsForIDs(ids).forEach(function(sock) {
181 | sock.data.items.push(item);
182 | syncDown(sock);
183 | });
184 | };
185 |
186 | module.exports.removeItem = function(id, idx) {
187 | const sock = socketsForIDs([parseInt(id, 10)])[0];
188 | if (sock) {
189 | sock.data.items.splice(idx, 1);
190 | syncDown(sock);
191 | }
192 | };
193 |
194 | module.exports.clearItems = function(ids) {
195 | socketsForIDs(ids).forEach(function(sock) {
196 | sock.data.items = [];
197 | syncDown(sock);
198 | });
199 | };
200 |
201 | module.exports.generateAdminUpdate = function(ids) {
202 | return Promise.all(socketsForIDs(ids).map(generateAdminUpdate));
203 | };
204 |
205 | module.exports.reload = function(ids){
206 |
207 | if(ids === undefined){
208 | app.io.of('/screens').emit('reload');
209 | } else {
210 | socketsForIDs(ids).map(function(socket){
211 | socket.emit('reload');
212 | })
213 | }
214 |
215 |
216 |
217 | };
218 |
--------------------------------------------------------------------------------
/wdio.conf.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /* global browser*/
3 |
4 | const denodeify = require('denodeify');
5 | const selenium = require('selenium-standalone');
6 | const installSelenium = denodeify(selenium.install.bind(selenium));
7 | const startSeleniumServer = denodeify(selenium.start.bind(selenium));
8 | const spawn = require('child_process').spawn;
9 | const express = require('express');
10 | const tabs = require('./tests/integration/lib/tabs');
11 | let server;
12 |
13 | /*
14 | * Installs Selenium and starts the server, ready to control browsers
15 | */
16 |
17 | function installAndStartSelenium () {
18 | return installSelenium()
19 | .then(startSeleniumServer)
20 | .then(child => {
21 | selenium.child = child;
22 | })
23 | .catch(e => {
24 | console.log('Selenium could not install or start', e);
25 | throw e;
26 | });
27 | }
28 |
29 | let failures;
30 |
31 | exports.config = {
32 |
33 | // ==================
34 | // Specify Test Files
35 | // ==================
36 | // Define which test specs should run. The pattern is relative to the directory
37 | // from which `wdio` was called. Notice that, if you are calling `wdio` from an
38 | // NPM script (see https://docs.npmjs.com/cli/run-script) then the current working
39 | // directory is where your package.json resides, so `wdio` will be called from there.
40 | //
41 | specs: [
42 | './tests/integration/*.js'
43 | ],
44 |
45 | // Patterns to exclude.
46 | exclude: [
47 | // 'path/to/excluded/files'
48 | ],
49 |
50 | // ============
51 | // Capabilities
52 | // ============
53 | // Define your capabilities here. WebdriverIO can run multiple capabilties at the same
54 | // time. Depending on the number of capabilities, WebdriverIO launches several test
55 | // sessions. Within your capabilities you can overwrite the spec and exclude option in
56 | // order to group specific specs to a specific capability.
57 | //
58 | // If you have trouble getting all important capabilities together, check out the
59 | // Sauce Labs platform configurator - a great tool to configure your capabilities:
60 | // https://docs.saucelabs.com/reference/platforms-configurator
61 | //
62 | capabilities: [{
63 | browserName: 'chrome'
64 | }],
65 |
66 | // ===================
67 | // Test Configurations
68 | // ===================
69 | // Define all options that are relevant for the WebdriverIO instance here
70 | //
71 | // Level of logging verbosity: silent | verbose | command | data | result | error
72 | logLevel: 'error',
73 |
74 | // Enables colors for log output.
75 | coloredLogs: true,
76 |
77 | // Saves a screenshot to a given path if a command fails.
78 | screenshotPath: './errorShots/',
79 |
80 | // Set a base URL in order to shorten url command calls. If your url parameter starts
81 | // with "/", the base url gets prepended.
82 | baseUrl: 'http://localhost:3010',
83 |
84 | // Default timeout for all waitForXXX commands.
85 | waitforTimeout: 10000,
86 |
87 | // Initialize the browser instance with a WebdriverIO plugin. The object should have the
88 | // plugin name as key and the desired plugin options as property. Make sure you have
89 | // the plugin installed before running any tests. The following plugins are currently
90 | // available:
91 | // WebdriverCSS: https://github.com/webdriverio/webdrivercss
92 | // WebdriverRTC: https://github.com/webdriverio/webdriverrtc
93 | // Browserevent: https://github.com/webdriverio/browserevent
94 | // plugins: {
95 | // webdrivercss: {
96 | // screenshotRoot: 'my-shots',
97 | // failedComparisonsRoot: 'diffs',
98 | // misMatchTolerance: 0.05,
99 | // screenWidth: [320,480,640,1024]
100 | // },
101 | // webdriverrtc: {},
102 | // browserevent: {}
103 | // },
104 | //
105 | // Framework you want to run your specs with.
106 | // The following are supported: mocha, jasmine and cucumber
107 | // see also: http://webdriver.io/guide/testrunner/frameworks.html
108 | //
109 | // Make sure you have the node package for the specific framework installed before running
110 | // any tests. If not please install the following package:
111 | // Mocha: `$ npm install mocha`
112 | // Jasmine: `$ npm install jasmine`
113 | // Cucumber: `$ npm install cucumber`
114 | framework: 'mocha',
115 |
116 | // Test reporter for stdout.
117 | // The following are supported: dot (default), spec and xunit
118 | // see also: http://webdriver.io/guide/testrunner/reporters.html
119 | reporter: 'dot',
120 |
121 | // Options to be passed to Mocha.
122 | // See the full list at http://mochajs.org/
123 | mochaOpts: {
124 | ui: 'bdd'
125 | },
126 |
127 | // =====
128 | // Hooks
129 | // =====
130 | // Run functions before or after the test. If one of them returns with a promise, WebdriverIO
131 | // will wait until that promise got resolved to continue.
132 | //
133 | // Gets executed before all workers get launched.
134 | onPrepare: function() {
135 | server = spawn('bin/www')
136 | .on('error', function (err) {
137 | console.log('Failed to start child process.', err);
138 | });
139 | return installAndStartSelenium();
140 | },
141 |
142 | // Gets executed before test execution begins. At this point you will have access to all global
143 | // variables like `browser`. It is the perfect place to define custom commands.
144 | before: function() {
145 |
146 | const Tab = tabs.getTabController(browser).Tab;
147 |
148 | const testWebsiteServer = express();
149 | testWebsiteServer.get('/emptyresponse', (req,res) => res.status(200).end());
150 | testWebsiteServer.listen(3011);
151 |
152 | // Set cookie to bypass auth
153 | return browser.url('/__about')
154 | .localStorage('POST', {key: 'viewerData_v2', value: JSON.stringify(
155 | {
156 | id:12345,
157 | items:[],
158 | name:"Test Page"
159 | }
160 | )})
161 | .setCookie({name: 'webdriver', value: '__webdriverTesting__'})
162 |
163 | // open tabs before the tests start.
164 | .getCurrentTabId()
165 | .then(handle => new Tab('about', {handle}).ready())
166 | .then(() => new Tab('admin', {url: '/admin'}).ready())
167 | .then(() => new Tab('viewer', {url: '/'}).ready());
168 | },
169 |
170 | // Gets executed after all tests are done. You still have access to all global variables from
171 | // the test.
172 | after: function(failedTests, pid) {
173 | process.kill(pid);
174 | failures = failedTests;
175 | console.log('FAILURES' + failures);
176 | },
177 |
178 | // Gets executed after all workers got shut down and the process is about to exit. It is not
179 | // possible to defer the end of the process using a promise.
180 | onComplete: function() {
181 | selenium.child.kill();
182 | server.kill();
183 | }
184 | };
185 |
--------------------------------------------------------------------------------
/client/generator-carousel-admin/js/main.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | 'use strict';
3 | const default_url = 'https://en.wikipedia.org/wiki/Financial_Times';
4 | const default_duration = 10;
5 |
6 | let keyUpTimeout;
7 | let tableBody;
8 | let templateInputBox;
9 |
10 | function removeRow(e) {
11 | let row;
12 | if(e.target.className === 'remove') {
13 | row = e.currentTarget;
14 | row.removeEventListener('click', removeRow);
15 | tableBody.removeChild(row);
16 | }
17 | checkAndAddMoreForms();
18 | }
19 |
20 | function appendNewInputToForm(n){
21 | let newRow;
22 | for (let i = 0,l = n||1; i
0 && navigator.userAgent.indexOf('FTLabs-Screens') > 0);
10 | }
11 |
12 | const storage = {
13 | setItem : function(storageKey, data, callback){
14 |
15 | const info = localStorage.setItem(storageKey, JSON.stringify(data) );
16 | callback(info);
17 |
18 | },
19 | getItem : function(storageKey, callback){
20 |
21 | const info = localStorage.getItem(storageKey);
22 |
23 | if(info === null){
24 | callback(null);
25 | } else {
26 | callback( JSON.parse( info ) );
27 | }
28 | }
29 | };
30 |
31 | // Called by the script loader once the page has loaded
32 | window.screensInit = function screensInit() {
33 |
34 | const viewer = new Viewer(host, storage);
35 | let loadEvent = 'load';
36 |
37 | const DOM = {
38 | container: document.getElementById('container'),
39 | Iframe1: document.querySelector('iframe.first'),
40 | Iframe2: document.querySelector('iframe.second'),
41 | carouselCountdown: document.querySelector('#carousel-countdown')
42 | };
43 | let carousel;
44 |
45 | function switchOutIframeForWebview() {
46 |
47 | loadEvent = "dom-ready";
48 | const webViewElement1 = document.createElement('webview');
49 | const webViewElement2 = document.createElement('webview');
50 |
51 | webViewElement1.setAttribute('class', DOM.Iframe1.getAttribute('class'));
52 | webViewElement2.setAttribute('class', DOM.Iframe2.getAttribute('class'));
53 |
54 | DOM.Iframe1.parentNode.removeChild(DOM.Iframe1);
55 | DOM.Iframe2.parentNode.removeChild(DOM.Iframe2);
56 | DOM.Iframe1 = webViewElement1;
57 | DOM.Iframe2 = webViewElement2;
58 |
59 | DOM.container.appendChild(DOM.Iframe1);
60 | DOM.container.appendChild(DOM.Iframe2);
61 |
62 | }
63 |
64 | function updateTitle() {
65 | const name = viewer.getData('name') || viewer.getData('id');
66 | document.title = name + ' : FT Screens';
67 | }
68 |
69 | function updateIDs() {
70 | [].slice.call(document.querySelectorAll('.screen-id')).forEach(function(el) {
71 | el.innerHTML = viewer.getData('id');
72 | });
73 | }
74 |
75 | if (viewerIsRunningInElectron()) {
76 | switchOutIframeForWebview();
77 | }
78 |
79 | function iframeLoaded() {
80 | const currentActive = document.querySelector('.active');
81 | if (currentActive) kickOutIframe(currentActive);
82 | this.classList.remove('buffering');
83 | this.classList.add('active');
84 | this.removeEventListener(loadEvent, iframeLoaded);
85 | }
86 |
87 | function kickOutIframe(iframe) {
88 | iframe.classList.remove('active');
89 | iframe.classList.remove('buffering');
90 | iframe.classList.add('done');
91 | setTimeout(() => iframe.src = 'about:blank', 500);
92 | iframe.removeEventListener(loadEvent, iframeLoaded);
93 |
94 | // remove self from the list
95 | usedIframes.splice(usedIframes.indexOf(iframe), 1);
96 | }
97 |
98 | function prepareIframetoLoad(iframe, url) {
99 | usedIframes.push(iframe);
100 | iframe.classList.add('buffering');
101 | iframe.classList.remove('done');
102 | iframe.src = url;
103 | iframe.addEventListener(loadEvent, iframeLoaded);
104 | }
105 |
106 | const availableIframes = [
107 | DOM.Iframe1,
108 | DOM.Iframe2
109 | ];
110 | const usedIframes = [
111 | DOM.Iframe1
112 | ];
113 | function updateUrl(url) {
114 | if (!url) {
115 | return;
116 | }
117 |
118 | DOM.Iframe1.style.pointerEvents = 'none';
119 | DOM.Iframe2.style.pointerEvents = 'none';
120 |
121 | // another url has been added
122 | if (usedIframes.length < availableIframes.length) {
123 | const nextIframe = availableIframes.filter(iframe => usedIframes.indexOf(iframe) === -1)[0];
124 | prepareIframetoLoad(nextIframe, url);
125 | return;
126 | }
127 |
128 | // a third has been added kick up the first one so the next one can load
129 | if (usedIframes.length === availableIframes.length) {
130 | const next = usedIframes[0];
131 | kickOutIframe(next);
132 | prepareIframetoLoad(next, url);
133 |
134 | // load the next iframe regardless
135 | iframeLoaded.bind(usedIframes[0])();
136 | return;
137 | }
138 | }
139 |
140 | DOM.container.addEventListener('click', function () {
141 | DOM.Iframe1.style.pointerEvents = 'auto';
142 | DOM.Iframe2.style.pointerEvents = 'auto';
143 | });
144 |
145 | // The url has changed
146 | viewer.on('change', function(url) {
147 |
148 | if (carousel) {
149 | // stop timers
150 | carousel.destroy();
151 | carousel = null;
152 | DOM.carouselCountdown.style.transform = 'scaleX(0)';
153 | DOM.carouselCountdown.style.transition = 'none';
154 | DOM.carouselCountdown.style.offsetHeight;
155 | }
156 |
157 | if (Carousel.isCarousel(url)) {
158 | carousel = new Carousel(url, host);
159 | carousel.on('change', function (url) {
160 | updateUrl(url);
161 | DOM.carouselCountdown.style.transition = 'none';
162 | DOM.carouselCountdown.style.transform = 'scaleX(1)';
163 |
164 | setTimeout(() => {
165 | let duration = carousel.timeUntilNext();
166 | DOM.carouselCountdown.style.transition = `transform ${duration}ms linear`;
167 | DOM.carouselCountdown.style.transform = 'scaleX(0)';
168 | }, 100);
169 | });
170 | updateUrl(carousel.getCurrentURL());
171 | } else {
172 | updateUrl(url);
173 | }
174 | });
175 |
176 | viewer.on('id-change', function () {
177 | updateTitle();
178 | updateIDs();
179 | });
180 |
181 | // A reload has been forced
182 | viewer.on('reload', () => {
183 | DOM.Iframe1.src = DOM.Iframe1.src;
184 | DOM.Iframe2.src = DOM.Iframe2.src;
185 | });
186 |
187 | // E.g. The viewer has started but cannot connected to the server.
188 | viewer.on('not-connected', () => {
189 | DOM.container.classList.add('state-disconnected');
190 | });
191 |
192 | viewer.on('ready', function(){
193 | setInterval(function () {
194 | updateTitle();
195 | updateIDs();
196 | DOM.container.classList.toggle('state-disconnected', !viewer.ready());
197 | DOM.container.classList.remove('state-active', 'state-hello', 'state-loading');
198 |
199 | let state;
200 |
201 | if (viewer.getUrl()){
202 | state = 'state-active';
203 | } else if(viewer.ready()){
204 | state = 'state-hello';
205 | } else {
206 | state = 'state-loading';
207 | }
208 |
209 | DOM.container.classList.add(state);
210 |
211 | }, 1000);
212 | });
213 |
214 | viewer.start();
215 |
216 | };
217 |
218 | // Initialise Origami components when the page has loaded
219 | if (document.readyState === 'interactive' || document.readyState === 'complete') {
220 | document.dispatchEvent(new CustomEvent('o.DOMContentLoaded'));
221 | }
222 |
223 | document.addEventListener('DOMContentLoaded', function() {
224 |
225 | // Dispatch a custom event that will tell all required modules to initialise
226 | document.dispatchEvent(new CustomEvent('o.DOMContentLoaded'));
227 | });
228 |
--------------------------------------------------------------------------------
/client/admin/js/main.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | /* global io, console */
3 |
4 | const $ = require('jquery');
5 | const api = require('../../common/js/api');
6 | let socket;
7 | const filters = require('./filter');
8 | const renamescreens = require('./renamescreens');
9 | const removeitem = require('./removeitem');
10 | const moment = require('moment');
11 |
12 | const HierarchicalNav = require('o-hierarchical-nav');
13 | const nav = document.querySelector('.o-hierarchical-nav');
14 | new HierarchicalNav(nav);
15 |
16 | const troubleURLS = [];
17 |
18 | function pointOutTroubleMakers(){
19 | const activeLinks = Array.from(document.querySelectorAll('.screen-page'));
20 |
21 | activeLinks.forEach(function(activeLink){
22 |
23 | const link = activeLink.getAttribute('href');
24 |
25 | troubleURLS.forEach(function(url){
26 | if(url === link){
27 | activeLink.setAttribute('data-troublesome-url', 'true');
28 | }
29 | });
30 |
31 |
32 | })
33 |
34 | }
35 |
36 | function updateScreen(data) {
37 | const $el = $('#screen-'+data.id);
38 | console.log('Screen update: ' + data.id, $el);
39 | if (data.content && $el.length) {
40 | const checkstate = $el.find('input.screen-select').prop('checked');
41 |
42 | const panelDom = $(data.content)
43 | panelDom.find('input.screen-select').prop('checked', checkstate);
44 | $el.replaceWith(panelDom);
45 | filters.apply();
46 | } else if (data.content) {
47 | $('#screens tbody').append(data.content);
48 | filters.apply();
49 | } else if ($el.length) {
50 | markOffline($el.toArray());
51 | filters.apply();
52 | }
53 | updateCloneList();
54 | resizeTable();
55 | dateTime();
56 | orderTable();
57 | pointOutTroubleMakers();
58 | }
59 |
60 | function orderTable() {
61 | let rows = Array.prototype.slice.call(document.querySelectorAll('tr'));
62 | rows = rows.sort(function(a,b) {
63 |
64 | a = a.querySelector('label').title.toLowerCase();
65 |
66 | b = b.querySelector('label').title.toLowerCase();
67 |
68 | if(a < b) return -1;
69 | if(a > b) return 1;
70 | return 0;
71 | });
72 |
73 | const tableBody = document.querySelector('tbody');
74 |
75 | tableBody.innerHTML = '';
76 |
77 | rows.forEach(function(row){
78 | tableBody.appendChild(row);
79 | });
80 | }
81 |
82 | function markOffline(els) {
83 | els.forEach(el => {
84 | console.log(typeof el, el);
85 | el.classList.add('screen-offline');
86 | });
87 | }
88 |
89 | function dateTime() {
90 | $('.item-info-scheduled').toArray().forEach(el => {
91 | const sched = moment(el.dataset.dateTimeSchedule, 'x');
92 | const happensToday = sched.isSame(new Date(), 'day');
93 | const happenedAlready = sched.isBefore(new Date());
94 |
95 | if (happenedAlready) return;
96 | if (happensToday) {
97 | el.innerHTML = 'Scheduled for ' + sched.format('HH:mm');
98 | } else {
99 | el.innerHTML = 'Scheduled for ' + sched.toLocaleString();
100 | }
101 | });
102 | }
103 |
104 | function updateAllScreens(data) {
105 | let touched = $();
106 | data.forEach(function(update) {
107 | updateScreen(update);
108 | touched = touched.add('#screen-'+update.id);
109 | });
110 | markOffline($('.screen').not(touched).toArray());
111 | }
112 |
113 | function updateCloneList() {
114 | let li;
115 | const outHTML = $('.screens tr').toArray()
116 | .map(row => '' +
117 | row.querySelector('label').innerText +
118 | ((li = row.querySelector('.queue li')) ? ' - ' + li.innerText : '') +
119 | ' '
120 | ).join('');
121 | const screenSelector = document.querySelector('#selscreen');
122 | if (screenSelector) screenSelector.innerHTML = outHTML;
123 | }
124 |
125 | function resizeTable() {
126 | [].slice.call(document.querySelectorAll('.screen td:last-child')).forEach(td => {
127 | td.style.maxWidth = document.querySelector('.page h1').offsetWidth - td.previousElementSibling.offsetWidth + 'px';
128 | });
129 | }
130 |
131 | function getSelectedScreens() {
132 | return $('.screen-select:checked').map(function() {
133 | return this.value;
134 | }).get();
135 | }
136 |
137 | function screensInit() {
138 |
139 | const port = location.port ? ':'+location.port : '';
140 | socket = io.connect('//'+location.hostname+port+'/admins');
141 | socket.on('screenData', updateScreen);
142 | socket.on('allScreensData', updateAllScreens);
143 |
144 | $('#actions_set-content, #actions_clear, #actions_clone, #actions_reload, #actions_hold, #actions_reload_some').on('submit', function(e) {
145 | e.preventDefault();
146 | const screens = getSelectedScreens();
147 | if (!screens.length && !$(this).is('#actions_reload')) return window.alert('Choose some screens first');
148 | const data = {screens: screens.join(',')};
149 | if ($(this).is('#actions_set-content')) {
150 | data.url = $('#txturl').val();
151 | data.duration = $('#selurlduration').val();
152 | api('addUrl', data)
153 | .then(function(res){
154 | const canBeViewed = res.viewable;
155 |
156 | if(!canBeViewed){
157 |
158 | if(troubleURLS.indexOf(data.url) === -1){
159 | troubleURLS.push(data.url);
160 | }
161 |
162 | }
163 | })
164 | ;
165 | } else if ($(this).is('#actions_clear')) {
166 | api('clear', data);
167 | } else if ($(this).is('#actions_clone')) {
168 | const fromId = $('#selscreen').val();
169 | const dataCache = [].slice.call(document.querySelectorAll('tr[data-id="' + fromId + '"] a'))
170 | .map(a => ({
171 | screens: data.screens,
172 | url: a.href,
173 | duration: a.dataset.expires ? (a.dataset.expires - Date.now()) / 1000 : -1,
174 | dateTimeSchedule: a.dataset.dateTimeSchedule
175 | }));
176 | api('clear', data)
177 | .then(function () {
178 | (function recurse() {
179 | if (dataCache.length) api('addUrl', dataCache.pop()).then(recurse);
180 | }());
181 | });
182 | } else if($(this).is('#actions_reload')){
183 | api('reload', {});
184 | } else if( $(this).is('#actions_reload_some') ){
185 | api('reload', {screens: screens.join(',')});
186 | } else if($(this).is('#actions_hold')){
187 | data.url = 'http://'+location.hostname+port+'/generators/standby?title=Holding%20Page';
188 | data.duration = $('#selholdduration').val();
189 | api('addUrl', data);
190 | }
191 |
192 | });
193 |
194 | $('#selection').on('change', function() {
195 | $('.action-options').removeAttr('aria-selected');
196 | $('#actions_'+this.value).attr('aria-selected', true);
197 | });
198 |
199 | $('#chkselectall').on('click', function() {
200 | $('input.screen-select:visible').prop('checked', this.checked);
201 | });
202 | $('.screens').on('click', 'input.screen-select', function() {
203 | if (!this.checked) $('#chkselectall').prop('checked', false);
204 | });
205 |
206 | filters.init($);
207 | renamescreens.init($);
208 | removeitem.init($);
209 |
210 | const txturl = document.getElementById('txturl');
211 | if (txturl) txturl.onblur = function checkURL (urlField) {
212 | let url = urlField.target.value;
213 | if (!url.match(/^\w+:/)) {
214 | url = 'http://' + url;
215 | }
216 | urlField.target.value = url;
217 | return urlField;
218 | };
219 | };
220 |
221 | // Initialise Origami components when the page has loaded
222 | if (document.readyState === 'interactive' || document.readyState === 'complete') {
223 | document.dispatchEvent(new CustomEvent('o.DOMContentLoaded'));
224 | }
225 |
226 | document.addEventListener('DOMContentLoaded', function() {
227 |
228 | // Dispatch a custom event that will tell all required modules to initialise
229 | document.dispatchEvent(new CustomEvent('o.DOMContentLoaded'));
230 | });
231 | window.addEventListener('resize', resizeTable);
232 |
233 | window.screensInit = screensInit;
234 |
--------------------------------------------------------------------------------
/server/routes/api.js:
--------------------------------------------------------------------------------
1 | /* global process */
2 |
3 | 'use strict'; //eslint-disable-line strict
4 | const router = require('express').Router(); // eslint-disable-line new-cap
5 | const debug = require('debug')('screens:api');
6 | const screens = require('../screens');
7 | const moment = require('moment');
8 | const request = require('request');
9 | const transform = require('../urls');
10 | const transformedUrls = {};
11 | const log = require('../log');
12 | const pages = require('../pages');
13 | const auth = require('../middleware/auth/'+(process.env.AUTH_BACKEND || 'ft-s3o'));
14 |
15 | function checkIsViewable(url){
16 |
17 | return new Promise(function(resolve, reject){
18 |
19 | request({
20 | method: 'head',
21 | uri: url
22 | }, function(err, res){
23 |
24 | debug(res.headers);
25 |
26 | if(err){
27 | reject(err);
28 | } else {
29 |
30 | if(res.headers['x-frame-options'] === undefined){
31 | resolve(true);
32 | } else {
33 | resolve(false);
34 | }
35 |
36 | }
37 |
38 | });
39 |
40 |
41 | });
42 |
43 | }
44 |
45 | function cachedTransform( url, host ){
46 | let promise;
47 | if (url in transformedUrls) {
48 | debug('cachedTransform: cache hit: url=', url);
49 | promise = Promise.resolve( transformedUrls[url] );
50 | } else {
51 | debug('cachedTransform: cache miss: url=', url);
52 | promise = transform( url, host)
53 | .then(function(transformedUrl){
54 | transformedUrls[url] = transformedUrl;
55 | return transformedUrl;
56 | })
57 | ;
58 | }
59 |
60 | return promise;
61 | }
62 |
63 | function getScreenIDsForRequest(req) {
64 | return req.body.screens.split(',').map(function(n) { return parseInt(n, 10); });
65 | }
66 |
67 | router.post('/getShortUrl', function (req, res) {
68 | if (!req.body.id) return res.status(400).send('Missing ID');
69 |
70 | const longUrl = 'http://' + req.get('host') + '/admin?filter=' + req.body.id + '&redirect=true';
71 | let responsePromise = Promise.resolve({});
72 |
73 | if (process.env.BITLY_LOGIN && process.env.BITLY_API_KEY) {
74 | const postdata = {
75 | login: process.env.BITLY_LOGIN,
76 | apiKey: process.env.BITLY_API_KEY,
77 | longUrl: longUrl
78 | };
79 | const qs = Object.keys(postdata).reduce(function(a,k){ a.push(k+'='+encodeURIComponent(postdata[k])); return a }, []).join('&');
80 | responsePromise = fetch('https://api-ssl.bitly.com/v3/shorten', {
81 | method: 'POST',
82 | headers: { 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8' },
83 | body: qs
84 | }).then(function (respStream) {
85 | return respStream.json();
86 | }).then(function (data) {
87 | return data.data || {};
88 | });
89 | }
90 |
91 | return responsePromise.then(function(resp) {
92 | const response = {
93 | url: resp.url || longUrl
94 | };
95 | res.json(response);
96 | });
97 | });
98 |
99 | router.get('/transformUrl/:url', function(req, res){
100 | cachedTransform( req.params.url, req.get('host'))
101 | .then(function(tfmd_url){
102 | res.send(tfmd_url);
103 | })
104 | ;
105 | });
106 |
107 | router.post('*', auth);
108 |
109 | router.post('/addUrl', function(req, res) {
110 | if (!req.body.url) return res.status(400).send('Missing url');
111 |
112 | cachedTransform(req.body.url, req.get('host'))
113 | .then(function(url){
114 |
115 | const ids = getScreenIDsForRequest(req);
116 | const dur = parseInt(req.body.duration, 10);
117 |
118 | // Ensure items with no schedule appear before each other but after scheduled content
119 | const dateTimeSchedule = req.body.dateTimeSchedule || parseInt(Date.now()/100, 10);
120 |
121 | // if dateTimeSchedule is not set have it expire after a certain amount of time
122 | // if the client or server time is incorrect then this will be wrong.
123 | let expires;
124 | if (dur !== -1) {
125 | if (req.body.dateTimeSchedule) {
126 | expires = moment(dateTimeSchedule, 'x');
127 | } else {
128 | expires = (moment()).add(dur, 'seconds').valueOf();
129 | }
130 | }
131 |
132 | const content = {
133 | url,
134 | expires,
135 | dateTimeSchedule
136 | };
137 |
138 | debug('url:', url);
139 | debug('scheduled:', new Date(moment(dateTimeSchedule, 'x').valueOf()))
140 | debug('expires:', new Date(expires));
141 |
142 | screens.pushItem(ids, content);
143 |
144 | const title = pages(url).getTitle();
145 |
146 | debug(req.cookies.s3o_username + ' added URL '+req.body.url+' to screens '+ids);
147 | ids.forEach(id => {
148 | log.logApi({
149 | eventType: log.eventTypes.screenContentAssignment.id,
150 | screenId: id,
151 | username: req.cookies.s3o_username,
152 | details: {
153 | url: req.body.url,
154 | title,
155 | duration: dur
156 | }
157 | });
158 | });
159 |
160 | return checkIsViewable(url)
161 | .then(isViewable => {
162 | res.json({
163 | viewable : isViewable
164 | });
165 | })
166 | .catch(() => {
167 | res.json(true);
168 | })
169 | ;
170 |
171 | }).catch(e => debug(e.message || e));
172 | ;
173 |
174 | });
175 |
176 | router.post('/clear', function(req, res) {
177 | const ids = getScreenIDsForRequest(req);
178 | screens.clearItems(ids);
179 | debug(req.cookies.s3o_username + ' cleared screens ' + ids);
180 | ids.forEach(id => {
181 | log.logApi({
182 | eventType: log.eventTypes.screenContentCleared.id,
183 | screenId: id,
184 | username: req.cookies.s3o_username
185 | });
186 | });
187 | res.json(true);
188 | });
189 |
190 | router.post('/rename', function(req, res) {
191 | const name = req.body.name;
192 | const id = getScreenIDsForRequest(req);
193 | const screen = screens.get(id)[0];
194 | const oldName = screen ? screen.name : 'No screen present';
195 | debug(req.cookies.s3o_username + ' renamed screen ' + id[0] + ' to ' + name);
196 | log.logApi({
197 | eventType: log.eventTypes.screenRenamed.id,
198 | screenId: id[0],
199 | username: req.cookies.s3o_username,
200 | details: {
201 | newName: name,
202 | oldName
203 | }
204 | });
205 | screens.set(id, {name});
206 | res.json(true);
207 | });
208 |
209 | router.post('/remove', function(req, res) {
210 |
211 | const oldUrl = screens.get(req.body.screen)[0].items[req.body.idx].url;
212 | log.logApi({
213 | eventType: log.eventTypes.screenContentRemoval.id,
214 | screenId: req.body.screen,
215 | username: req.cookies.s3o_username,
216 | details: {
217 | itemTitle: pages(oldUrl).getTitle(),
218 | itemUrl: oldUrl
219 | }
220 | });
221 |
222 | screens.removeItem(req.body.screen, req.body.idx);
223 | res.json(true);
224 | });
225 |
226 | router.post('/reload', function(req, res) {
227 |
228 | if(Object.keys(req.body).length !== 0){
229 | const ids = getScreenIDsForRequest(req);
230 | screens.reload(ids);
231 | ids.forEach(id => {
232 | log.logApi({
233 | eventType: log.eventTypes.screenReloaded.id,
234 | screenId: id,
235 | username: req.cookies.s3o_username
236 | });
237 | });
238 | } else {
239 | debug(req.cookies.s3o_username + ' reloaded all screens');
240 | screens.reload();
241 | log.logApi({
242 | eventType: log.eventTypes.allScreensReloaded.id,
243 | username: req.cookies.s3o_username
244 | });
245 | }
246 |
247 | res.json(true);
248 | });
249 |
250 | router.post('/is-viewable', function(req, res){
251 |
252 | const url = req.body.url;
253 |
254 | fetch(url, {
255 | method: 'head'
256 | })
257 | .then(function(response){
258 | return response.headers.get('x-frame-options');
259 | })
260 | .then(function(xFrameHeader){
261 |
262 | if(xFrameHeader === null){
263 | res.json({
264 | viewable : true,
265 | header : null
266 | });
267 | } else {
268 | res.json({
269 | viewable : false,
270 | header : xFrameHeader
271 | });
272 | }
273 |
274 | })
275 | .catch(function(err){
276 |
277 | res.status(500).json({
278 | error : err
279 | });
280 |
281 | })
282 | ;
283 |
284 | });
285 |
286 | module.exports = router;
287 |
--------------------------------------------------------------------------------
/tests/integration/viewer.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /*global describe, it, browser, before, afterEach, beforeEach*/
3 |
4 | const chai = require('chai');
5 | const chaiAsPromised = require('chai-as-promised');
6 | const browserLogs = require('./lib/logs')(browser);
7 | const tabController = require('./lib/tabs').getTabController(browser);
8 | const tabs = tabController.tabs;
9 | const Tab = tabController.Tab;
10 | const debounce = require('lodash.debounce');
11 | const debouncedLog = debounce(function (a) {
12 | console.log(a);
13 | }, 1500);
14 | chai.use(chaiAsPromised);
15 |
16 | const expect = chai.expect;
17 |
18 | const emptyScreenWebsite = 'http://localhost:3010/generators/empty-screen?id=12345';
19 |
20 | function waitABit() {
21 | return new Promise(resolve => setTimeout(resolve, 10000));
22 | }
23 |
24 | // go to the admin page set a url
25 | function addItem(url, duration, scheduledTime) {
26 |
27 | // 0 or undefined are not valid durations
28 | duration = duration || 60;
29 |
30 | // Log to the console what is about to be done
31 | console.log(`Setting Url: ${url}
32 | Duration: ${duration}`);
33 |
34 | return tabs['admin'].switchTo()
35 | .waitForExist('#chkscreen-12345')
36 | .click(`#selection option[value="set-content"]`)
37 | .click(`#selurlduration option[value="${duration}"]`)
38 | .setValue('#txturl', url)
39 | .isSelected('#chkscreen-12345')
40 | .then(tick => {
41 | console.log('Submitting request');
42 | if (!tick) return browser.click('label[for=chkscreen-12345]');
43 | })
44 | .click('#btnsetcontent');
45 | }
46 |
47 | // go to the admin page pop off the top of the queue
48 | function removeItem(url) {
49 | const xSelector = `.queue li[data-url="${url}"] .action-remove`;
50 |
51 | return tabs['admin'].switchTo()
52 | .waitForExist(xSelector)
53 | .click(xSelector)
54 | .then(undefined, function (e) {
55 | console.warn('Remove item failed');
56 | console.warn(e);
57 | });
58 | }
59 |
60 | function printLogOnError(e) {
61 |
62 | // show browser console.logs
63 | return browserLogs()
64 | .then(function (logs) {
65 | console.log('BROWSER LOGS: \n' + logs);
66 | throw e;
67 | });
68 | }
69 |
70 | function logs() {
71 | browserLogs()
72 | .then(function () {
73 |
74 | // Do nothing so that they get flushed
75 | });
76 | }
77 |
78 | function waitForIFrameUrl(urlIn, timeout) {
79 |
80 | let oldUrl;
81 | timeout = timeout || 10000;
82 |
83 | console.log('Waiting for iframe to become url: ' + urlIn + ', ' + timeout + ' timeout');
84 |
85 | return tabs['viewer'].switchTo()
86 | .waitForExist('iframe.active')
87 | .getAttribute('iframe.active','src')
88 | .then(url => console.log(`Url was initially ${url}`))
89 | .waitUntil(function() {
90 |
91 | // wait for the iframe's url to change
92 | return browser
93 | .waitForExist('iframe.active')
94 | .getAttribute('iframe.active','src')
95 | .then(url => {
96 | debouncedLog('Last url: ' + url);
97 | oldUrl = url;
98 | return url.indexOf(urlIn) === 0;
99 | });
100 | }, timeout) // default timeout is
101 | .then(() => debouncedLog('MATCH!!'))
102 | .then(waitABit)
103 | .catch(e => {
104 | const newMessage = `Errored waiting for url to load in iframe: ${urlIn} url was ${oldUrl}`;
105 | console.log(e.message);
106 | console.log(newMessage);
107 | throw Error(newMessage);
108 | });
109 | ;
110 | }
111 |
112 | describe('Viewer responds to API requests', () => {
113 |
114 | const initialUrl = 'http://ftlabs-screens.herokuapp.com/generators/markdown?md=%23Initial&theme=dark';
115 |
116 | before('gets an ID', function () {
117 |
118 | const id = tabs['viewer'].switchTo()
119 | .waitForText('#hello .screen-id')
120 | .waitForVisible('#hello .screen-id')
121 | .getText('#hello .screen-id')
122 | .then(undefined, function (e) {
123 | console.log(e);
124 | });
125 |
126 | return expect(id).to.eventually.equal('12345')
127 | .then(logs, printLogOnError);
128 | });
129 |
130 | beforeEach(function(){
131 | console.log(`Starting: "${this.currentTest.title}"`)
132 | console.log('↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓\n');
133 | });
134 |
135 | afterEach(function(){
136 | console.log('\n↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑');
137 | console.log(`Completed: "${this.currentTest.title}"`);
138 | });
139 |
140 | /**
141 | * Load Url
142 | *
143 | * Add a url to a screen it should now show the new url,
144 | * this should be set to not expire. it'll be present through out all
145 | * the test except at the end of the final test which clears all urls.
146 | */
147 |
148 | it('can have a url assigned', function () {
149 |
150 | this.timeout(45000);
151 |
152 | return addItem(initialUrl, -1)
153 | .then(() => waitForIFrameUrl(initialUrl))
154 | .then(logs, printLogOnError);
155 | });
156 |
157 | /**
158 | * Can have url removed
159 | */
160 |
161 | it('removes a url via the admin panel', function () {
162 |
163 | const testWebsite = 'http://ftlabs-screens.herokuapp.com/generators/markdown?md=%23One&theme=dark';
164 |
165 | this.timeout(60000);
166 |
167 | return addItem(testWebsite)
168 | .then(() => waitForIFrameUrl(testWebsite))
169 | .then(() => removeItem(testWebsite))
170 | .then(() => waitForIFrameUrl(initialUrl))
171 | .then(logs, printLogOnError); });
172 |
173 | /**
174 | * Can add a url which has an empty response
175 | */
176 |
177 | it('Can add a url which has an empty response', function () {
178 |
179 | this.timeout(45000);
180 |
181 | const emptyResponseUrl = 'http://localhost:3011/emptyresponse';
182 | return addItem(emptyResponseUrl)
183 | .then(() => tabs['viewer'].switchTo())
184 | .then(() => waitForIFrameUrl(emptyResponseUrl))
185 | .then(() => removeItem(emptyResponseUrl))
186 | .then(() => waitForIFrameUrl(initialUrl))
187 | .then(logs, printLogOnError);
188 | });
189 |
190 |
191 | /**
192 | * Can correctly idenitify an image
193 | */
194 |
195 | it('correctly processes an image url', function () {
196 |
197 | this.timeout(45000);
198 |
199 | const imageGeneratorUrl = 'http://localhost:3010/generators/image/?https%3A%2F%2Fimage.webservices.ft.com%2Fv1%2Fimages%2Fraw%2Fhttps%253A%252F%252Fupload.wikimedia.org%252Fwikipedia%252Fcommons%252Fthumb%252F3%252F30%252FSmall_bird_perching_on_a_branch.jpg%252F512px-Small_bird_perching_on_a_branch.jpg%3Fsource%3Dscreens&title=512px-Small_bird_perching_on_a_branch.jpg';
200 | const imageResponseUrl = 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Small_bird_perching_on_a_branch.jpg/512px-Small_bird_perching_on_a_branch.jpg';
201 | return addItem(imageResponseUrl)
202 | .then(() => waitForIFrameUrl(imageGeneratorUrl))
203 | .then(() => removeItem(imageGeneratorUrl))
204 | .then(() => waitForIFrameUrl(initialUrl))
205 | .then(logs, printLogOnError);
206 | });
207 |
208 | /**
209 | * Load another Url to the screen that expires after 60s
210 | *
211 | * Add a url to a screen it should now be the new url
212 | *
213 | * After 60s it should be removed
214 | */
215 |
216 | it('removes a url after a specified amount of time', function () {
217 | this.timeout(120000);
218 |
219 | let startTime;
220 | const testWebsite = 'http://ftlabs-screens.herokuapp.com/generators/markdown?md=%23Three&theme=dark';
221 |
222 | return addItem(testWebsite)
223 | .then(() => waitForIFrameUrl(testWebsite))
224 | .then(() => (startTime = Date.now()))
225 | .then(() => waitForIFrameUrl(initialUrl, 69000))
226 | .then(function () {
227 | if (Date.now() - startTime < 59000) {
228 | throw Error('The website expired too quickly! ' + (Date.now() - startTime));
229 | }
230 | })
231 | .then(logs, printLogOnError);
232 | });
233 |
234 |
235 | /**
236 | * Close the viewer tab
237 | * Change the localStorage to have no idUpdated and name but the same id.
238 | * Expect the id to be changed
239 | */
240 |
241 | xit('will have it\'s id reassigned', function () {
242 | this.timeout(120000);
243 |
244 | return tabs['viewer'].close()
245 | .then(() => tabs['about'].switchTo())
246 | .localStorage('POST', {key: 'viewerData_v2', value: JSON.stringify(
247 | {
248 | id:12345,
249 | items:[],
250 | name:'Test Page 2',
251 | idUpdated: Date.now()
252 | }
253 | )})
254 | .then(function () {
255 | const newViewerTab = new Tab('viewer', {
256 | url: '/'
257 | });
258 | return newViewerTab.ready();
259 | })
260 | .then(waitABit) // wait a few seconds for a bit of back and forth to get the id reassigned
261 | .then(() => {
262 | const id = browser
263 | .getText('#hello .screen-id')
264 | .then(undefined, function (e) {
265 | console.log(e);
266 | });
267 |
268 | return expect(id).to.eventually.not.equal('12345')
269 | })
270 | .then(logs, printLogOnError);
271 | });
272 | });
273 |
--------------------------------------------------------------------------------
/views/generators-ticker-viewer.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
36 |
37 |
38 |
39 |
230 |
231 |
307 |
308 |
311 |
--------------------------------------------------------------------------------
/client/admin/scss/lib/font-awesome/_variables.scss:
--------------------------------------------------------------------------------
1 | // Variables
2 | // --------------------------
3 |
4 | $fa-font-path: "../fonts" !default;
5 | $fa-font-size-base: 14px !default;
6 | $fa-line-height-base: 1 !default;
7 | //$fa-font-path: "//netdna.bootstrapcdn.com/font-awesome/4.4.0/fonts" !default; // for referencing Bootstrap CDN font files directly
8 | $fa-css-prefix: fa !default;
9 | $fa-version: "4.4.0" !default;
10 | $fa-border-color: #eee !default;
11 | $fa-inverse: #fff !default;
12 | $fa-li-width: (30em / 14) !default;
13 |
14 | $fa-var-500px: "\f26e";
15 | $fa-var-adjust: "\f042";
16 | $fa-var-adn: "\f170";
17 | $fa-var-align-center: "\f037";
18 | $fa-var-align-justify: "\f039";
19 | $fa-var-align-left: "\f036";
20 | $fa-var-align-right: "\f038";
21 | $fa-var-amazon: "\f270";
22 | $fa-var-ambulance: "\f0f9";
23 | $fa-var-anchor: "\f13d";
24 | $fa-var-android: "\f17b";
25 | $fa-var-angellist: "\f209";
26 | $fa-var-angle-double-down: "\f103";
27 | $fa-var-angle-double-left: "\f100";
28 | $fa-var-angle-double-right: "\f101";
29 | $fa-var-angle-double-up: "\f102";
30 | $fa-var-angle-down: "\f107";
31 | $fa-var-angle-left: "\f104";
32 | $fa-var-angle-right: "\f105";
33 | $fa-var-angle-up: "\f106";
34 | $fa-var-apple: "\f179";
35 | $fa-var-archive: "\f187";
36 | $fa-var-area-chart: "\f1fe";
37 | $fa-var-arrow-circle-down: "\f0ab";
38 | $fa-var-arrow-circle-left: "\f0a8";
39 | $fa-var-arrow-circle-o-down: "\f01a";
40 | $fa-var-arrow-circle-o-left: "\f190";
41 | $fa-var-arrow-circle-o-right: "\f18e";
42 | $fa-var-arrow-circle-o-up: "\f01b";
43 | $fa-var-arrow-circle-right: "\f0a9";
44 | $fa-var-arrow-circle-up: "\f0aa";
45 | $fa-var-arrow-down: "\f063";
46 | $fa-var-arrow-left: "\f060";
47 | $fa-var-arrow-right: "\f061";
48 | $fa-var-arrow-up: "\f062";
49 | $fa-var-arrows: "\f047";
50 | $fa-var-arrows-alt: "\f0b2";
51 | $fa-var-arrows-h: "\f07e";
52 | $fa-var-arrows-v: "\f07d";
53 | $fa-var-asterisk: "\f069";
54 | $fa-var-at: "\f1fa";
55 | $fa-var-automobile: "\f1b9";
56 | $fa-var-backward: "\f04a";
57 | $fa-var-balance-scale: "\f24e";
58 | $fa-var-ban: "\f05e";
59 | $fa-var-bank: "\f19c";
60 | $fa-var-bar-chart: "\f080";
61 | $fa-var-bar-chart-o: "\f080";
62 | $fa-var-barcode: "\f02a";
63 | $fa-var-bars: "\f0c9";
64 | $fa-var-battery-0: "\f244";
65 | $fa-var-battery-1: "\f243";
66 | $fa-var-battery-2: "\f242";
67 | $fa-var-battery-3: "\f241";
68 | $fa-var-battery-4: "\f240";
69 | $fa-var-battery-empty: "\f244";
70 | $fa-var-battery-full: "\f240";
71 | $fa-var-battery-half: "\f242";
72 | $fa-var-battery-quarter: "\f243";
73 | $fa-var-battery-three-quarters: "\f241";
74 | $fa-var-bed: "\f236";
75 | $fa-var-beer: "\f0fc";
76 | $fa-var-behance: "\f1b4";
77 | $fa-var-behance-square: "\f1b5";
78 | $fa-var-bell: "\f0f3";
79 | $fa-var-bell-o: "\f0a2";
80 | $fa-var-bell-slash: "\f1f6";
81 | $fa-var-bell-slash-o: "\f1f7";
82 | $fa-var-bicycle: "\f206";
83 | $fa-var-binoculars: "\f1e5";
84 | $fa-var-birthday-cake: "\f1fd";
85 | $fa-var-bitbucket: "\f171";
86 | $fa-var-bitbucket-square: "\f172";
87 | $fa-var-bitcoin: "\f15a";
88 | $fa-var-black-tie: "\f27e";
89 | $fa-var-bold: "\f032";
90 | $fa-var-bolt: "\f0e7";
91 | $fa-var-bomb: "\f1e2";
92 | $fa-var-book: "\f02d";
93 | $fa-var-bookmark: "\f02e";
94 | $fa-var-bookmark-o: "\f097";
95 | $fa-var-briefcase: "\f0b1";
96 | $fa-var-btc: "\f15a";
97 | $fa-var-bug: "\f188";
98 | $fa-var-building: "\f1ad";
99 | $fa-var-building-o: "\f0f7";
100 | $fa-var-bullhorn: "\f0a1";
101 | $fa-var-bullseye: "\f140";
102 | $fa-var-bus: "\f207";
103 | $fa-var-buysellads: "\f20d";
104 | $fa-var-cab: "\f1ba";
105 | $fa-var-calculator: "\f1ec";
106 | $fa-var-calendar: "\f073";
107 | $fa-var-calendar-check-o: "\f274";
108 | $fa-var-calendar-minus-o: "\f272";
109 | $fa-var-calendar-o: "\f133";
110 | $fa-var-calendar-plus-o: "\f271";
111 | $fa-var-calendar-times-o: "\f273";
112 | $fa-var-camera: "\f030";
113 | $fa-var-camera-retro: "\f083";
114 | $fa-var-car: "\f1b9";
115 | $fa-var-caret-down: "\f0d7";
116 | $fa-var-caret-left: "\f0d9";
117 | $fa-var-caret-right: "\f0da";
118 | $fa-var-caret-square-o-down: "\f150";
119 | $fa-var-caret-square-o-left: "\f191";
120 | $fa-var-caret-square-o-right: "\f152";
121 | $fa-var-caret-square-o-up: "\f151";
122 | $fa-var-caret-up: "\f0d8";
123 | $fa-var-cart-arrow-down: "\f218";
124 | $fa-var-cart-plus: "\f217";
125 | $fa-var-cc: "\f20a";
126 | $fa-var-cc-amex: "\f1f3";
127 | $fa-var-cc-diners-club: "\f24c";
128 | $fa-var-cc-discover: "\f1f2";
129 | $fa-var-cc-jcb: "\f24b";
130 | $fa-var-cc-mastercard: "\f1f1";
131 | $fa-var-cc-paypal: "\f1f4";
132 | $fa-var-cc-stripe: "\f1f5";
133 | $fa-var-cc-visa: "\f1f0";
134 | $fa-var-certificate: "\f0a3";
135 | $fa-var-chain: "\f0c1";
136 | $fa-var-chain-broken: "\f127";
137 | $fa-var-check: "\f00c";
138 | $fa-var-check-circle: "\f058";
139 | $fa-var-check-circle-o: "\f05d";
140 | $fa-var-check-square: "\f14a";
141 | $fa-var-check-square-o: "\f046";
142 | $fa-var-chevron-circle-down: "\f13a";
143 | $fa-var-chevron-circle-left: "\f137";
144 | $fa-var-chevron-circle-right: "\f138";
145 | $fa-var-chevron-circle-up: "\f139";
146 | $fa-var-chevron-down: "\f078";
147 | $fa-var-chevron-left: "\f053";
148 | $fa-var-chevron-right: "\f054";
149 | $fa-var-chevron-up: "\f077";
150 | $fa-var-child: "\f1ae";
151 | $fa-var-chrome: "\f268";
152 | $fa-var-circle: "\f111";
153 | $fa-var-circle-o: "\f10c";
154 | $fa-var-circle-o-notch: "\f1ce";
155 | $fa-var-circle-thin: "\f1db";
156 | $fa-var-clipboard: "\f0ea";
157 | $fa-var-clock-o: "\f017";
158 | $fa-var-clone: "\f24d";
159 | $fa-var-close: "\f00d";
160 | $fa-var-cloud: "\f0c2";
161 | $fa-var-cloud-download: "\f0ed";
162 | $fa-var-cloud-upload: "\f0ee";
163 | $fa-var-cny: "\f157";
164 | $fa-var-code: "\f121";
165 | $fa-var-code-fork: "\f126";
166 | $fa-var-codepen: "\f1cb";
167 | $fa-var-coffee: "\f0f4";
168 | $fa-var-cog: "\f013";
169 | $fa-var-cogs: "\f085";
170 | $fa-var-columns: "\f0db";
171 | $fa-var-comment: "\f075";
172 | $fa-var-comment-o: "\f0e5";
173 | $fa-var-commenting: "\f27a";
174 | $fa-var-commenting-o: "\f27b";
175 | $fa-var-comments: "\f086";
176 | $fa-var-comments-o: "\f0e6";
177 | $fa-var-compass: "\f14e";
178 | $fa-var-compress: "\f066";
179 | $fa-var-connectdevelop: "\f20e";
180 | $fa-var-contao: "\f26d";
181 | $fa-var-copy: "\f0c5";
182 | $fa-var-copyright: "\f1f9";
183 | $fa-var-creative-commons: "\f25e";
184 | $fa-var-credit-card: "\f09d";
185 | $fa-var-crop: "\f125";
186 | $fa-var-crosshairs: "\f05b";
187 | $fa-var-css3: "\f13c";
188 | $fa-var-cube: "\f1b2";
189 | $fa-var-cubes: "\f1b3";
190 | $fa-var-cut: "\f0c4";
191 | $fa-var-cutlery: "\f0f5";
192 | $fa-var-dashboard: "\f0e4";
193 | $fa-var-dashcube: "\f210";
194 | $fa-var-database: "\f1c0";
195 | $fa-var-dedent: "\f03b";
196 | $fa-var-delicious: "\f1a5";
197 | $fa-var-desktop: "\f108";
198 | $fa-var-deviantart: "\f1bd";
199 | $fa-var-diamond: "\f219";
200 | $fa-var-digg: "\f1a6";
201 | $fa-var-dollar: "\f155";
202 | $fa-var-dot-circle-o: "\f192";
203 | $fa-var-download: "\f019";
204 | $fa-var-dribbble: "\f17d";
205 | $fa-var-dropbox: "\f16b";
206 | $fa-var-drupal: "\f1a9";
207 | $fa-var-edit: "\f044";
208 | $fa-var-eject: "\f052";
209 | $fa-var-ellipsis-h: "\f141";
210 | $fa-var-ellipsis-v: "\f142";
211 | $fa-var-empire: "\f1d1";
212 | $fa-var-envelope: "\f0e0";
213 | $fa-var-envelope-o: "\f003";
214 | $fa-var-envelope-square: "\f199";
215 | $fa-var-eraser: "\f12d";
216 | $fa-var-eur: "\f153";
217 | $fa-var-euro: "\f153";
218 | $fa-var-exchange: "\f0ec";
219 | $fa-var-exclamation: "\f12a";
220 | $fa-var-exclamation-circle: "\f06a";
221 | $fa-var-exclamation-triangle: "\f071";
222 | $fa-var-expand: "\f065";
223 | $fa-var-expeditedssl: "\f23e";
224 | $fa-var-external-link: "\f08e";
225 | $fa-var-external-link-square: "\f14c";
226 | $fa-var-eye: "\f06e";
227 | $fa-var-eye-slash: "\f070";
228 | $fa-var-eyedropper: "\f1fb";
229 | $fa-var-facebook: "\f09a";
230 | $fa-var-facebook-f: "\f09a";
231 | $fa-var-facebook-official: "\f230";
232 | $fa-var-facebook-square: "\f082";
233 | $fa-var-fast-backward: "\f049";
234 | $fa-var-fast-forward: "\f050";
235 | $fa-var-fax: "\f1ac";
236 | $fa-var-feed: "\f09e";
237 | $fa-var-female: "\f182";
238 | $fa-var-fighter-jet: "\f0fb";
239 | $fa-var-file: "\f15b";
240 | $fa-var-file-archive-o: "\f1c6";
241 | $fa-var-file-audio-o: "\f1c7";
242 | $fa-var-file-code-o: "\f1c9";
243 | $fa-var-file-excel-o: "\f1c3";
244 | $fa-var-file-image-o: "\f1c5";
245 | $fa-var-file-movie-o: "\f1c8";
246 | $fa-var-file-o: "\f016";
247 | $fa-var-file-pdf-o: "\f1c1";
248 | $fa-var-file-photo-o: "\f1c5";
249 | $fa-var-file-picture-o: "\f1c5";
250 | $fa-var-file-powerpoint-o: "\f1c4";
251 | $fa-var-file-sound-o: "\f1c7";
252 | $fa-var-file-text: "\f15c";
253 | $fa-var-file-text-o: "\f0f6";
254 | $fa-var-file-video-o: "\f1c8";
255 | $fa-var-file-word-o: "\f1c2";
256 | $fa-var-file-zip-o: "\f1c6";
257 | $fa-var-files-o: "\f0c5";
258 | $fa-var-film: "\f008";
259 | $fa-var-filter: "\f0b0";
260 | $fa-var-fire: "\f06d";
261 | $fa-var-fire-extinguisher: "\f134";
262 | $fa-var-firefox: "\f269";
263 | $fa-var-flag: "\f024";
264 | $fa-var-flag-checkered: "\f11e";
265 | $fa-var-flag-o: "\f11d";
266 | $fa-var-flash: "\f0e7";
267 | $fa-var-flask: "\f0c3";
268 | $fa-var-flickr: "\f16e";
269 | $fa-var-floppy-o: "\f0c7";
270 | $fa-var-folder: "\f07b";
271 | $fa-var-folder-o: "\f114";
272 | $fa-var-folder-open: "\f07c";
273 | $fa-var-folder-open-o: "\f115";
274 | $fa-var-font: "\f031";
275 | $fa-var-fonticons: "\f280";
276 | $fa-var-forumbee: "\f211";
277 | $fa-var-forward: "\f04e";
278 | $fa-var-foursquare: "\f180";
279 | $fa-var-frown-o: "\f119";
280 | $fa-var-futbol-o: "\f1e3";
281 | $fa-var-gamepad: "\f11b";
282 | $fa-var-gavel: "\f0e3";
283 | $fa-var-gbp: "\f154";
284 | $fa-var-ge: "\f1d1";
285 | $fa-var-gear: "\f013";
286 | $fa-var-gears: "\f085";
287 | $fa-var-genderless: "\f22d";
288 | $fa-var-get-pocket: "\f265";
289 | $fa-var-gg: "\f260";
290 | $fa-var-gg-circle: "\f261";
291 | $fa-var-gift: "\f06b";
292 | $fa-var-git: "\f1d3";
293 | $fa-var-git-square: "\f1d2";
294 | $fa-var-github: "\f09b";
295 | $fa-var-github-alt: "\f113";
296 | $fa-var-github-square: "\f092";
297 | $fa-var-gittip: "\f184";
298 | $fa-var-glass: "\f000";
299 | $fa-var-globe: "\f0ac";
300 | $fa-var-google: "\f1a0";
301 | $fa-var-google-plus: "\f0d5";
302 | $fa-var-google-plus-square: "\f0d4";
303 | $fa-var-google-wallet: "\f1ee";
304 | $fa-var-graduation-cap: "\f19d";
305 | $fa-var-gratipay: "\f184";
306 | $fa-var-group: "\f0c0";
307 | $fa-var-h-square: "\f0fd";
308 | $fa-var-hacker-news: "\f1d4";
309 | $fa-var-hand-grab-o: "\f255";
310 | $fa-var-hand-lizard-o: "\f258";
311 | $fa-var-hand-o-down: "\f0a7";
312 | $fa-var-hand-o-left: "\f0a5";
313 | $fa-var-hand-o-right: "\f0a4";
314 | $fa-var-hand-o-up: "\f0a6";
315 | $fa-var-hand-paper-o: "\f256";
316 | $fa-var-hand-peace-o: "\f25b";
317 | $fa-var-hand-pointer-o: "\f25a";
318 | $fa-var-hand-rock-o: "\f255";
319 | $fa-var-hand-scissors-o: "\f257";
320 | $fa-var-hand-spock-o: "\f259";
321 | $fa-var-hand-stop-o: "\f256";
322 | $fa-var-hdd-o: "\f0a0";
323 | $fa-var-header: "\f1dc";
324 | $fa-var-headphones: "\f025";
325 | $fa-var-heart: "\f004";
326 | $fa-var-heart-o: "\f08a";
327 | $fa-var-heartbeat: "\f21e";
328 | $fa-var-history: "\f1da";
329 | $fa-var-home: "\f015";
330 | $fa-var-hospital-o: "\f0f8";
331 | $fa-var-hotel: "\f236";
332 | $fa-var-hourglass: "\f254";
333 | $fa-var-hourglass-1: "\f251";
334 | $fa-var-hourglass-2: "\f252";
335 | $fa-var-hourglass-3: "\f253";
336 | $fa-var-hourglass-end: "\f253";
337 | $fa-var-hourglass-half: "\f252";
338 | $fa-var-hourglass-o: "\f250";
339 | $fa-var-hourglass-start: "\f251";
340 | $fa-var-houzz: "\f27c";
341 | $fa-var-html5: "\f13b";
342 | $fa-var-i-cursor: "\f246";
343 | $fa-var-ils: "\f20b";
344 | $fa-var-image: "\f03e";
345 | $fa-var-inbox: "\f01c";
346 | $fa-var-indent: "\f03c";
347 | $fa-var-industry: "\f275";
348 | $fa-var-info: "\f129";
349 | $fa-var-info-circle: "\f05a";
350 | $fa-var-inr: "\f156";
351 | $fa-var-instagram: "\f16d";
352 | $fa-var-institution: "\f19c";
353 | $fa-var-internet-explorer: "\f26b";
354 | $fa-var-intersex: "\f224";
355 | $fa-var-ioxhost: "\f208";
356 | $fa-var-italic: "\f033";
357 | $fa-var-joomla: "\f1aa";
358 | $fa-var-jpy: "\f157";
359 | $fa-var-jsfiddle: "\f1cc";
360 | $fa-var-key: "\f084";
361 | $fa-var-keyboard-o: "\f11c";
362 | $fa-var-krw: "\f159";
363 | $fa-var-language: "\f1ab";
364 | $fa-var-laptop: "\f109";
365 | $fa-var-lastfm: "\f202";
366 | $fa-var-lastfm-square: "\f203";
367 | $fa-var-leaf: "\f06c";
368 | $fa-var-leanpub: "\f212";
369 | $fa-var-legal: "\f0e3";
370 | $fa-var-lemon-o: "\f094";
371 | $fa-var-level-down: "\f149";
372 | $fa-var-level-up: "\f148";
373 | $fa-var-life-bouy: "\f1cd";
374 | $fa-var-life-buoy: "\f1cd";
375 | $fa-var-life-ring: "\f1cd";
376 | $fa-var-life-saver: "\f1cd";
377 | $fa-var-lightbulb-o: "\f0eb";
378 | $fa-var-line-chart: "\f201";
379 | $fa-var-link: "\f0c1";
380 | $fa-var-linkedin: "\f0e1";
381 | $fa-var-linkedin-square: "\f08c";
382 | $fa-var-linux: "\f17c";
383 | $fa-var-list: "\f03a";
384 | $fa-var-list-alt: "\f022";
385 | $fa-var-list-ol: "\f0cb";
386 | $fa-var-list-ul: "\f0ca";
387 | $fa-var-location-arrow: "\f124";
388 | $fa-var-lock: "\f023";
389 | $fa-var-long-arrow-down: "\f175";
390 | $fa-var-long-arrow-left: "\f177";
391 | $fa-var-long-arrow-right: "\f178";
392 | $fa-var-long-arrow-up: "\f176";
393 | $fa-var-magic: "\f0d0";
394 | $fa-var-magnet: "\f076";
395 | $fa-var-mail-forward: "\f064";
396 | $fa-var-mail-reply: "\f112";
397 | $fa-var-mail-reply-all: "\f122";
398 | $fa-var-male: "\f183";
399 | $fa-var-map: "\f279";
400 | $fa-var-map-marker: "\f041";
401 | $fa-var-map-o: "\f278";
402 | $fa-var-map-pin: "\f276";
403 | $fa-var-map-signs: "\f277";
404 | $fa-var-mars: "\f222";
405 | $fa-var-mars-double: "\f227";
406 | $fa-var-mars-stroke: "\f229";
407 | $fa-var-mars-stroke-h: "\f22b";
408 | $fa-var-mars-stroke-v: "\f22a";
409 | $fa-var-maxcdn: "\f136";
410 | $fa-var-meanpath: "\f20c";
411 | $fa-var-medium: "\f23a";
412 | $fa-var-medkit: "\f0fa";
413 | $fa-var-meh-o: "\f11a";
414 | $fa-var-mercury: "\f223";
415 | $fa-var-microphone: "\f130";
416 | $fa-var-microphone-slash: "\f131";
417 | $fa-var-minus: "\f068";
418 | $fa-var-minus-circle: "\f056";
419 | $fa-var-minus-square: "\f146";
420 | $fa-var-minus-square-o: "\f147";
421 | $fa-var-mobile: "\f10b";
422 | $fa-var-mobile-phone: "\f10b";
423 | $fa-var-money: "\f0d6";
424 | $fa-var-moon-o: "\f186";
425 | $fa-var-mortar-board: "\f19d";
426 | $fa-var-motorcycle: "\f21c";
427 | $fa-var-mouse-pointer: "\f245";
428 | $fa-var-music: "\f001";
429 | $fa-var-navicon: "\f0c9";
430 | $fa-var-neuter: "\f22c";
431 | $fa-var-newspaper-o: "\f1ea";
432 | $fa-var-object-group: "\f247";
433 | $fa-var-object-ungroup: "\f248";
434 | $fa-var-odnoklassniki: "\f263";
435 | $fa-var-odnoklassniki-square: "\f264";
436 | $fa-var-opencart: "\f23d";
437 | $fa-var-openid: "\f19b";
438 | $fa-var-opera: "\f26a";
439 | $fa-var-optin-monster: "\f23c";
440 | $fa-var-outdent: "\f03b";
441 | $fa-var-pagelines: "\f18c";
442 | $fa-var-paint-brush: "\f1fc";
443 | $fa-var-paper-plane: "\f1d8";
444 | $fa-var-paper-plane-o: "\f1d9";
445 | $fa-var-paperclip: "\f0c6";
446 | $fa-var-paragraph: "\f1dd";
447 | $fa-var-paste: "\f0ea";
448 | $fa-var-pause: "\f04c";
449 | $fa-var-paw: "\f1b0";
450 | $fa-var-paypal: "\f1ed";
451 | $fa-var-pencil: "\f040";
452 | $fa-var-pencil-square: "\f14b";
453 | $fa-var-pencil-square-o: "\f044";
454 | $fa-var-phone: "\f095";
455 | $fa-var-phone-square: "\f098";
456 | $fa-var-photo: "\f03e";
457 | $fa-var-picture-o: "\f03e";
458 | $fa-var-pie-chart: "\f200";
459 | $fa-var-pied-piper: "\f1a7";
460 | $fa-var-pied-piper-alt: "\f1a8";
461 | $fa-var-pinterest: "\f0d2";
462 | $fa-var-pinterest-p: "\f231";
463 | $fa-var-pinterest-square: "\f0d3";
464 | $fa-var-plane: "\f072";
465 | $fa-var-play: "\f04b";
466 | $fa-var-play-circle: "\f144";
467 | $fa-var-play-circle-o: "\f01d";
468 | $fa-var-plug: "\f1e6";
469 | $fa-var-plus: "\f067";
470 | $fa-var-plus-circle: "\f055";
471 | $fa-var-plus-square: "\f0fe";
472 | $fa-var-plus-square-o: "\f196";
473 | $fa-var-power-off: "\f011";
474 | $fa-var-print: "\f02f";
475 | $fa-var-puzzle-piece: "\f12e";
476 | $fa-var-qq: "\f1d6";
477 | $fa-var-qrcode: "\f029";
478 | $fa-var-question: "\f128";
479 | $fa-var-question-circle: "\f059";
480 | $fa-var-quote-left: "\f10d";
481 | $fa-var-quote-right: "\f10e";
482 | $fa-var-ra: "\f1d0";
483 | $fa-var-random: "\f074";
484 | $fa-var-rebel: "\f1d0";
485 | $fa-var-recycle: "\f1b8";
486 | $fa-var-reddit: "\f1a1";
487 | $fa-var-reddit-square: "\f1a2";
488 | $fa-var-refresh: "\f021";
489 | $fa-var-registered: "\f25d";
490 | $fa-var-remove: "\f00d";
491 | $fa-var-renren: "\f18b";
492 | $fa-var-reorder: "\f0c9";
493 | $fa-var-repeat: "\f01e";
494 | $fa-var-reply: "\f112";
495 | $fa-var-reply-all: "\f122";
496 | $fa-var-retweet: "\f079";
497 | $fa-var-rmb: "\f157";
498 | $fa-var-road: "\f018";
499 | $fa-var-rocket: "\f135";
500 | $fa-var-rotate-left: "\f0e2";
501 | $fa-var-rotate-right: "\f01e";
502 | $fa-var-rouble: "\f158";
503 | $fa-var-rss: "\f09e";
504 | $fa-var-rss-square: "\f143";
505 | $fa-var-rub: "\f158";
506 | $fa-var-ruble: "\f158";
507 | $fa-var-rupee: "\f156";
508 | $fa-var-safari: "\f267";
509 | $fa-var-save: "\f0c7";
510 | $fa-var-scissors: "\f0c4";
511 | $fa-var-search: "\f002";
512 | $fa-var-search-minus: "\f010";
513 | $fa-var-search-plus: "\f00e";
514 | $fa-var-sellsy: "\f213";
515 | $fa-var-send: "\f1d8";
516 | $fa-var-send-o: "\f1d9";
517 | $fa-var-server: "\f233";
518 | $fa-var-share: "\f064";
519 | $fa-var-share-alt: "\f1e0";
520 | $fa-var-share-alt-square: "\f1e1";
521 | $fa-var-share-square: "\f14d";
522 | $fa-var-share-square-o: "\f045";
523 | $fa-var-shekel: "\f20b";
524 | $fa-var-sheqel: "\f20b";
525 | $fa-var-shield: "\f132";
526 | $fa-var-ship: "\f21a";
527 | $fa-var-shirtsinbulk: "\f214";
528 | $fa-var-shopping-cart: "\f07a";
529 | $fa-var-sign-in: "\f090";
530 | $fa-var-sign-out: "\f08b";
531 | $fa-var-signal: "\f012";
532 | $fa-var-simplybuilt: "\f215";
533 | $fa-var-sitemap: "\f0e8";
534 | $fa-var-skyatlas: "\f216";
535 | $fa-var-skype: "\f17e";
536 | $fa-var-slack: "\f198";
537 | $fa-var-sliders: "\f1de";
538 | $fa-var-slideshare: "\f1e7";
539 | $fa-var-smile-o: "\f118";
540 | $fa-var-soccer-ball-o: "\f1e3";
541 | $fa-var-sort: "\f0dc";
542 | $fa-var-sort-alpha-asc: "\f15d";
543 | $fa-var-sort-alpha-desc: "\f15e";
544 | $fa-var-sort-amount-asc: "\f160";
545 | $fa-var-sort-amount-desc: "\f161";
546 | $fa-var-sort-asc: "\f0de";
547 | $fa-var-sort-desc: "\f0dd";
548 | $fa-var-sort-down: "\f0dd";
549 | $fa-var-sort-numeric-asc: "\f162";
550 | $fa-var-sort-numeric-desc: "\f163";
551 | $fa-var-sort-up: "\f0de";
552 | $fa-var-soundcloud: "\f1be";
553 | $fa-var-space-shuttle: "\f197";
554 | $fa-var-spinner: "\f110";
555 | $fa-var-spoon: "\f1b1";
556 | $fa-var-spotify: "\f1bc";
557 | $fa-var-square: "\f0c8";
558 | $fa-var-square-o: "\f096";
559 | $fa-var-stack-exchange: "\f18d";
560 | $fa-var-stack-overflow: "\f16c";
561 | $fa-var-star: "\f005";
562 | $fa-var-star-half: "\f089";
563 | $fa-var-star-half-empty: "\f123";
564 | $fa-var-star-half-full: "\f123";
565 | $fa-var-star-half-o: "\f123";
566 | $fa-var-star-o: "\f006";
567 | $fa-var-steam: "\f1b6";
568 | $fa-var-steam-square: "\f1b7";
569 | $fa-var-step-backward: "\f048";
570 | $fa-var-step-forward: "\f051";
571 | $fa-var-stethoscope: "\f0f1";
572 | $fa-var-sticky-note: "\f249";
573 | $fa-var-sticky-note-o: "\f24a";
574 | $fa-var-stop: "\f04d";
575 | $fa-var-street-view: "\f21d";
576 | $fa-var-strikethrough: "\f0cc";
577 | $fa-var-stumbleupon: "\f1a4";
578 | $fa-var-stumbleupon-circle: "\f1a3";
579 | $fa-var-subscript: "\f12c";
580 | $fa-var-subway: "\f239";
581 | $fa-var-suitcase: "\f0f2";
582 | $fa-var-sun-o: "\f185";
583 | $fa-var-superscript: "\f12b";
584 | $fa-var-support: "\f1cd";
585 | $fa-var-table: "\f0ce";
586 | $fa-var-tablet: "\f10a";
587 | $fa-var-tachometer: "\f0e4";
588 | $fa-var-tag: "\f02b";
589 | $fa-var-tags: "\f02c";
590 | $fa-var-tasks: "\f0ae";
591 | $fa-var-taxi: "\f1ba";
592 | $fa-var-television: "\f26c";
593 | $fa-var-tencent-weibo: "\f1d5";
594 | $fa-var-terminal: "\f120";
595 | $fa-var-text-height: "\f034";
596 | $fa-var-text-width: "\f035";
597 | $fa-var-th: "\f00a";
598 | $fa-var-th-large: "\f009";
599 | $fa-var-th-list: "\f00b";
600 | $fa-var-thumb-tack: "\f08d";
601 | $fa-var-thumbs-down: "\f165";
602 | $fa-var-thumbs-o-down: "\f088";
603 | $fa-var-thumbs-o-up: "\f087";
604 | $fa-var-thumbs-up: "\f164";
605 | $fa-var-ticket: "\f145";
606 | $fa-var-times: "\f00d";
607 | $fa-var-times-circle: "\f057";
608 | $fa-var-times-circle-o: "\f05c";
609 | $fa-var-tint: "\f043";
610 | $fa-var-toggle-down: "\f150";
611 | $fa-var-toggle-left: "\f191";
612 | $fa-var-toggle-off: "\f204";
613 | $fa-var-toggle-on: "\f205";
614 | $fa-var-toggle-right: "\f152";
615 | $fa-var-toggle-up: "\f151";
616 | $fa-var-trademark: "\f25c";
617 | $fa-var-train: "\f238";
618 | $fa-var-transgender: "\f224";
619 | $fa-var-transgender-alt: "\f225";
620 | $fa-var-trash: "\f1f8";
621 | $fa-var-trash-o: "\f014";
622 | $fa-var-tree: "\f1bb";
623 | $fa-var-trello: "\f181";
624 | $fa-var-tripadvisor: "\f262";
625 | $fa-var-trophy: "\f091";
626 | $fa-var-truck: "\f0d1";
627 | $fa-var-try: "\f195";
628 | $fa-var-tty: "\f1e4";
629 | $fa-var-tumblr: "\f173";
630 | $fa-var-tumblr-square: "\f174";
631 | $fa-var-turkish-lira: "\f195";
632 | $fa-var-tv: "\f26c";
633 | $fa-var-twitch: "\f1e8";
634 | $fa-var-twitter: "\f099";
635 | $fa-var-twitter-square: "\f081";
636 | $fa-var-umbrella: "\f0e9";
637 | $fa-var-underline: "\f0cd";
638 | $fa-var-undo: "\f0e2";
639 | $fa-var-university: "\f19c";
640 | $fa-var-unlink: "\f127";
641 | $fa-var-unlock: "\f09c";
642 | $fa-var-unlock-alt: "\f13e";
643 | $fa-var-unsorted: "\f0dc";
644 | $fa-var-upload: "\f093";
645 | $fa-var-usd: "\f155";
646 | $fa-var-user: "\f007";
647 | $fa-var-user-md: "\f0f0";
648 | $fa-var-user-plus: "\f234";
649 | $fa-var-user-secret: "\f21b";
650 | $fa-var-user-times: "\f235";
651 | $fa-var-users: "\f0c0";
652 | $fa-var-venus: "\f221";
653 | $fa-var-venus-double: "\f226";
654 | $fa-var-venus-mars: "\f228";
655 | $fa-var-viacoin: "\f237";
656 | $fa-var-video-camera: "\f03d";
657 | $fa-var-vimeo: "\f27d";
658 | $fa-var-vimeo-square: "\f194";
659 | $fa-var-vine: "\f1ca";
660 | $fa-var-vk: "\f189";
661 | $fa-var-volume-down: "\f027";
662 | $fa-var-volume-off: "\f026";
663 | $fa-var-volume-up: "\f028";
664 | $fa-var-warning: "\f071";
665 | $fa-var-wechat: "\f1d7";
666 | $fa-var-weibo: "\f18a";
667 | $fa-var-weixin: "\f1d7";
668 | $fa-var-whatsapp: "\f232";
669 | $fa-var-wheelchair: "\f193";
670 | $fa-var-wifi: "\f1eb";
671 | $fa-var-wikipedia-w: "\f266";
672 | $fa-var-windows: "\f17a";
673 | $fa-var-won: "\f159";
674 | $fa-var-wordpress: "\f19a";
675 | $fa-var-wrench: "\f0ad";
676 | $fa-var-xing: "\f168";
677 | $fa-var-xing-square: "\f169";
678 | $fa-var-y-combinator: "\f23b";
679 | $fa-var-y-combinator-square: "\f1d4";
680 | $fa-var-yahoo: "\f19e";
681 | $fa-var-yc: "\f23b";
682 | $fa-var-yc-square: "\f1d4";
683 | $fa-var-yelp: "\f1e9";
684 | $fa-var-yen: "\f157";
685 | $fa-var-youtube: "\f167";
686 | $fa-var-youtube-play: "\f16a";
687 | $fa-var-youtube-square: "\f166";
688 |
689 |
--------------------------------------------------------------------------------