├── public ├── scripts │ └── core.js ├── favicon.ico ├── pointer.png ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 └── index_old.html ├── Procfile ├── views ├── screen.handlebars ├── generators-rtc-viewer.handlebars ├── error.handlebars ├── generators-rtc-creator.handlebars ├── generators-ftvideo-viewer.handlebars ├── generators-carousel-view.handlebars ├── generators-layout-view.handlebars ├── logs.handlebars ├── generators-youtube-player.handlebars ├── partials │ ├── screen.handlebars │ └── header.handlebars ├── generators-home.handlebars ├── generators-id-viewer.handlebars ├── generators-standby-viewer.handlebars ├── generators-image-viewer.handlebars ├── generators-ticker-admin.handlebars ├── generators-carousel-admin.handlebars ├── viewer.handlebars ├── admin.handlebars ├── generators-markdown-view.handlebars ├── generators-layout-admin.handlebars ├── layouts │ └── main.handlebars ├── generators-markdown-admin.handlebars └── generators-ticker-viewer.handlebars ├── .github └── CODEOWNERS ├── client ├── admin │ ├── js │ │ ├── keycodes.js │ │ ├── parse-params.js │ │ ├── removeitem.js │ │ ├── renamescreens.js │ │ ├── filter.js │ │ └── main.js │ └── scss │ │ ├── lib │ │ └── font-awesome │ │ │ ├── _fixed-width.scss │ │ │ ├── _larger.scss │ │ │ ├── _list.scss │ │ │ ├── font-awesome.scss │ │ │ ├── _core.scss │ │ │ ├── _stacked.scss │ │ │ ├── _bordered-pulled.scss │ │ │ ├── _rotated-flipped.scss │ │ │ ├── _path.scss │ │ │ ├── _animated.scss │ │ │ ├── _mixins.scss │ │ │ └── _variables.scss │ │ └── main.scss ├── generator-rtc-admin │ ├── scss │ │ └── main.scss │ └── js │ │ └── main.js ├── generator-rtc-view │ ├── scss │ │ └── main.scss │ └── js │ │ └── main.js ├── logs │ ├── scss │ │ └── main.scss │ └── js │ │ └── main.js ├── generator-carousel-view │ └── js │ │ └── main.js ├── common │ ├── js │ │ └── api.js │ └── scss │ │ └── _admin-common.scss ├── generator-carousel-admin │ ├── scss │ │ └── main.scss │ └── js │ │ └── main.js ├── generator-layout-admin │ └── js │ │ └── main.js ├── generator-youtube-player │ ├── scss │ │ └── main.scss │ └── js │ │ └── main.js ├── viewer │ ├── scss │ │ └── main.scss │ └── js │ │ └── main.js └── generator-layout-view │ └── js │ └── main.js ├── .bowerrc ├── .gitignore ├── server ├── routes │ ├── viewer.js │ ├── admin.js │ ├── index.js │ ├── generators.js │ └── api.js ├── sentry.js ├── middleware │ └── auth │ │ ├── http-basic.js │ │ └── ft-s3o.js ├── renderAdminPage.js ├── pages.js ├── urls.js ├── log.js ├── app.js └── screens.js ├── tests ├── integration │ ├── lib │ │ ├── logs.js │ │ └── tabs.js │ └── viewer.js ├── healthcheck.js └── unit │ └── test.js ├── bower.json ├── circle.yml ├── .eslintrc.json ├── runbook.json ├── package.json ├── bin └── www ├── README.md ├── gulpfile.js └── wdio.conf.js /public/scripts/core.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node ./bin/www 2 | -------------------------------------------------------------------------------- /views/screen.handlebars: -------------------------------------------------------------------------------- 1 | {{>screen}} 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Guessed from commit history 2 | * @Financial-Times/newproducts 3 | -------------------------------------------------------------------------------- /client/admin/js/keycodes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ESC: 27, 3 | ENTER: 13 4 | }; 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/screens/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/pointer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/screens/HEAD/public/pointer.png -------------------------------------------------------------------------------- /public/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/screens/HEAD/public/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /client/generator-rtc-admin/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import '../../admin/scss/main'; 2 | 3 | video { 4 | height: 80vh; 5 | } 6 | -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/screens/HEAD/public/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/screens/HEAD/public/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/screens/HEAD/public/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/screens/HEAD/public/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /views/generators-rtc-viewer.handlebars: -------------------------------------------------------------------------------- 1 | 2 |
3 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "registry": { 3 | "search": [ 4 | "http://registry.origami.ft.com", 5 | "https://bower.herokuapp.com" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .sass-cache/ 3 | /.env 4 | /.npmrc 5 | /bower_components/ 6 | /node_modules/ 7 | /public/build/ 8 | .env 9 | npm-debug.log 10 | -------------------------------------------------------------------------------- /views/error.handlebars: -------------------------------------------------------------------------------- 1 | {{>header}} 2 |
3 |

{{message}}

4 |

{{error.status}}

5 |
{{error.stack}}
6 |
7 | -------------------------------------------------------------------------------- /views/generators-rtc-creator.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_fixed-width.scss: -------------------------------------------------------------------------------- 1 | // Fixed Width Icons 2 | // ------------------------- 3 | .#{$fa-css-prefix}-fw { 4 | width: (18em / 14); 5 | text-align: center; 6 | } 7 | -------------------------------------------------------------------------------- /client/generator-rtc-view/scss/main.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 100%; 4 | height: 100%; 5 | margin: 0; 6 | } 7 | 8 | video { 9 | width: 100%; 10 | height: auto; 11 | max-height: 100vh; 12 | } 13 | -------------------------------------------------------------------------------- /views/generators-ftvideo-viewer.handlebars: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/logs/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "../../common/scss/admin-common"; 2 | 3 | table { 4 | width: 100%; 5 | } 6 | 7 | tr:nth-child(even) { 8 | background: rgba(0,0,0,0.1); 9 | } 10 | 11 | td { 12 | padding: 0.2em 1em; 13 | } 14 | 15 | thead tr td { 16 | margin-bottom: 1em; 17 | font-weight: bold; 18 | border-bottom: 1px solid black; 19 | } 20 | -------------------------------------------------------------------------------- /server/routes/viewer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const router = require('express').Router(); // eslint-disable-line new-cap 3 | 4 | /* GET home page. */ 5 | router.get('/', function(req, res) { 6 | res.render('viewer', { 7 | app: 'viewer', 8 | hostname: req.headers.host, 9 | title: req.query.title 10 | }); 11 | }); 12 | 13 | module.exports = router; 14 | -------------------------------------------------------------------------------- /server/routes/admin.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); // eslint-disable-line new-cap 2 | 3 | const auth = require('../middleware/auth/'+(process.env.AUTH_BACKEND || 'ft-s3o')); 4 | 5 | const renderAdminPage = require('../renderAdminPage'); 6 | 7 | router.route('/').all(auth); 8 | router.get('/', renderAdminPage); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /client/admin/js/parse-params.js: -------------------------------------------------------------------------------- 1 | exports.parse = function(str) { 2 | 3 | if(str === undefined){ 4 | return {}; 5 | } 6 | 7 | const parsedParams = {}; 8 | const splitString = str.split('&'); 9 | 10 | splitString.forEach(function(s){ 11 | 12 | s = s.split('='); 13 | 14 | parsedParams[s[0]] = s[1]; 15 | 16 | }); 17 | 18 | return parsedParams; 19 | 20 | }; 21 | -------------------------------------------------------------------------------- /views/generators-carousel-view.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 20 | -------------------------------------------------------------------------------- /client/logs/js/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const timestampEls = [].slice.call(document.querySelectorAll('.convert-timestamp')); 4 | const moment = require('moment'); 5 | 6 | (function updateTime(){ 7 | timestampEls.forEach(function (el) { 8 | var now = moment(el.dataset.timestamp, 'x').fromNow(); 9 | el.innerHTML = now; 10 | }); 11 | setTimeout(updateTime, 5000); 12 | })(); 13 | -------------------------------------------------------------------------------- /tests/integration/lib/logs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(client) { 4 | return function () { 5 | return client.log('browser') 6 | .then(function (logs) { 7 | return logs. 8 | value 9 | .filter(i => !!i.message.trim()) 10 | .map(v => `${(new Date(Number(v.timestamp))).toTimeString()}: ${v.message}`) 11 | .join('\n'); 12 | }); 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /client/generator-carousel-view/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 'use strict'; 3 | 4 | const Carousel = require('ftlabs-screens-carousel'); 5 | const carousel = new Carousel(location.toString(), location.origin); 6 | const iframe = document.createElement('iframe'); 7 | document.body.appendChild(iframe); 8 | document.title = carousel.getTitle(); 9 | carousel.on('change', url => iframe.src = url); 10 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "screens", 3 | "dependencies": { 4 | "jquery": "~2.1.4", 5 | "localforage": "^1.2.7", 6 | "o-buttons": "~2.0.4", 7 | "o-colors": "^3.0.0", 8 | "o-fonts": "^1.6.7", 9 | "o-forms": "^1.0.3", 10 | "o-ft-icons": "~2.3.7", 11 | "o-grid": "^3.0.3", 12 | "o-header": "~3.0.6", 13 | "o-hierarchical-nav": "~2.1.1", 14 | "showdown": "^1.2.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /views/generators-layout-view.handlebars: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/admin/js/removeitem.js: -------------------------------------------------------------------------------- 1 | exports.init = function($) { 2 | 3 | const api = require('../../common/js/api'); 4 | 5 | $('.screens') 6 | .on('click', '.action-remove', function() { 7 | const $li = $(this).closest('li'); 8 | const $list = $li.closest('ol').find('li'); 9 | const idx = $list.index($li); 10 | api('remove', { 11 | screen: $(this).closest('.screen').attr('data-id'), 12 | idx: idx 13 | }); 14 | }) 15 | ; 16 | }; 17 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_larger.scss: -------------------------------------------------------------------------------- 1 | // Icon Sizes 2 | // ------------------------- 3 | 4 | /* makes the font 33% larger relative to the icon container */ 5 | .#{$fa-css-prefix}-lg { 6 | font-size: (4em / 3); 7 | line-height: (3em / 4); 8 | vertical-align: -15%; 9 | } 10 | .#{$fa-css-prefix}-2x { font-size: 2em; } 11 | .#{$fa-css-prefix}-3x { font-size: 3em; } 12 | .#{$fa-css-prefix}-4x { font-size: 4em; } 13 | .#{$fa-css-prefix}-5x { font-size: 5em; } 14 | -------------------------------------------------------------------------------- /client/common/js/api.js: -------------------------------------------------------------------------------- 1 | /* global fetch */ 2 | module.exports = function api(method, data) { 3 | const qs = Object.keys(data).reduce(function(a,k){ a.push(k+'='+encodeURIComponent(data[k])); return a }, []).join('&'); 4 | return fetch('/api/'+method, { 5 | method: 'POST', 6 | headers: { 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8' }, 7 | body: qs, 8 | credentials: 'same-origin' 9 | }).then(function(resp) { 10 | return resp.json(); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); // eslint-disable-line new-cap 2 | 3 | // GET home page 4 | router.get('/', function(req, res) { 5 | res.render('viewer', { 6 | app: 'viewer', 7 | hostname: req.headers.host, 8 | title: req.query.title 9 | }); 10 | }); 11 | 12 | // Vanity redirect for screen filtering 13 | router.get('/:id(\\d{3,5})', function(req, res) { 14 | res.redirect('/admin?filter='+req.params.id); 15 | }); 16 | 17 | module.exports = router; 18 | -------------------------------------------------------------------------------- /server/sentry.js: -------------------------------------------------------------------------------- 1 | const isProduction = process.env.NODE_ENV === 'production'; 2 | const SENTRY_DSN = process.env.SENTRY_DSN; 3 | const raven = require('raven'); 4 | const client = new raven.Client(isProduction && SENTRY_DSN); 5 | 6 | client.patchGlobal(); 7 | 8 | module.exports = client; 9 | module.exports.requestHandler = raven.middleware.express.requestHandler(isProduction && SENTRY_DSN); 10 | module.exports.errorHandler = raven.middleware.express.errorHandler(isProduction && SENTRY_DSN); 11 | -------------------------------------------------------------------------------- /views/logs.handlebars: -------------------------------------------------------------------------------- 1 | {{>header}} 2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{#logs}} 11 | 12 | 15 | 16 | {{/logs}} 17 | 18 |
TimestampSummaryUser
{{timestamp}}{{eventDesc}}
13 | {{#each details}}{{@key}}: {{this}}
14 | {{/each}}
{{username}}
19 |
20 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_list.scss: -------------------------------------------------------------------------------- 1 | // List Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-ul { 5 | padding-left: 0; 6 | margin-left: $fa-li-width; 7 | list-style-type: none; 8 | > li { position: relative; } 9 | } 10 | .#{$fa-css-prefix}-li { 11 | position: absolute; 12 | left: -$fa-li-width; 13 | width: $fa-li-width; 14 | top: (2em / 14); 15 | text-align: center; 16 | &.#{$fa-css-prefix}-lg { 17 | left: -$fa-li-width + (4em / 14); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/font-awesome.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */ 5 | 6 | @import "variables"; 7 | @import "mixins"; 8 | @import "path"; 9 | @import "core"; 10 | @import "larger"; 11 | @import "fixed-width"; 12 | @import "list"; 13 | @import "bordered-pulled"; 14 | @import "animated"; 15 | @import "rotated-flipped"; 16 | @import "stacked"; 17 | @import "icons"; 18 | -------------------------------------------------------------------------------- /client/generator-rtc-admin/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | /* global SimpleWebRTC */ 3 | const uuid = require('node-uuid'); 4 | const roomID = uuid.v4(); 5 | const roomname = document.getElementById('roomname'); 6 | 7 | const webrtc = new SimpleWebRTC({ 8 | localVideoEl: 'localVideo', 9 | autoRequestMedia: true 10 | }); 11 | 12 | webrtc.on('readyToCall', function (id) { 13 | roomname.textContent = 'http://' + window.location.host + '/generators/rtc?room=' + roomID + '&id=' + id; 14 | webrtc.joinRoom(roomID); 15 | }); 16 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_core.scss: -------------------------------------------------------------------------------- 1 | // Base Class Definition 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix} { 5 | display: inline-block; 6 | font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration 7 | font-size: inherit; // can't have font-size inherit on line above, so need to override 8 | text-rendering: auto; // optimizelegibility throws things off #1094 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 5.6.0 4 | dependencies: 5 | cache_directories: 6 | - "~/assets/chrome" # relative to the user's home directory 7 | override: 8 | - mkdir -p ~/assets/chrome/ 9 | - if [ ! -f ~/assets/chrome/chrome47.deb ]; then curl -L -o ~/assets/chrome/chrome47.deb https://s3.amazonaws.com/circle-downloads/google-chrome-stable_current_amd64_47.0.2526.73-1.deb; fi; 10 | - sudo dpkg -i ~/assets/chrome/chrome47.deb 11 | - sudo sed -i 's|HERE/chrome\"|HERE/chrome\" --disable-setuid-sandbox|g' /opt/google/chrome/google-chrome 12 | - npm install 13 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_stacked.scss: -------------------------------------------------------------------------------- 1 | // Stacked Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-stack { 5 | position: relative; 6 | display: inline-block; 7 | width: 2em; 8 | height: 2em; 9 | line-height: 2em; 10 | vertical-align: middle; 11 | } 12 | .#{$fa-css-prefix}-stack-1x, .#{$fa-css-prefix}-stack-2x { 13 | position: absolute; 14 | left: 0; 15 | width: 100%; 16 | text-align: center; 17 | } 18 | .#{$fa-css-prefix}-stack-1x { line-height: inherit; } 19 | .#{$fa-css-prefix}-stack-2x { font-size: 2em; } 20 | .#{$fa-css-prefix}-inverse { color: $fa-inverse; } 21 | -------------------------------------------------------------------------------- /views/generators-youtube-player.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 |
10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_bordered-pulled.scss: -------------------------------------------------------------------------------- 1 | // Bordered & Pulled 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-border { 5 | padding: .2em .25em .15em; 6 | border: solid .08em $fa-border-color; 7 | border-radius: .1em; 8 | } 9 | 10 | .#{$fa-css-prefix}-pull-left { float: left; } 11 | .#{$fa-css-prefix}-pull-right { float: right; } 12 | 13 | .#{$fa-css-prefix} { 14 | &.#{$fa-css-prefix}-pull-left { margin-right: .3em; } 15 | &.#{$fa-css-prefix}-pull-right { margin-left: .3em; } 16 | } 17 | 18 | /* Deprecated as of 4.4.0 */ 19 | .pull-right { float: right; } 20 | .pull-left { float: left; } 21 | 22 | .#{$fa-css-prefix} { 23 | &.pull-left { margin-right: .3em; } 24 | &.pull-right { margin-left: .3em; } 25 | } 26 | -------------------------------------------------------------------------------- /client/generator-carousel-admin/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "../../common/scss/admin-common"; 2 | 3 | label { 4 | user-select: none; 5 | } 6 | 7 | button { 8 | @include oButtons(big, standout); 9 | } 10 | 11 | input { 12 | @include oFormsCommonField; 13 | } 14 | 15 | label { 16 | font-weight: bold; 17 | } 18 | 19 | form { 20 | table { 21 | width: 100%; 22 | } 23 | 24 | .url-form-item { 25 | width:100%; 26 | margin-right: 1em; 27 | } 28 | 29 | .remove { 30 | font-weight: bold; 31 | color: black; 32 | text-decoration: none; 33 | width: 1em; 34 | text-align: center; 35 | display: inline-block; 36 | } 37 | 38 | 39 | .o-forms-group.row { 40 | margin-bottom: 1em; 41 | 42 | td { 43 | padding: 0 1em; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "ecmaFeatures": { 9 | "modules": false 10 | }, 11 | "rules": { 12 | "no-unused-vars": 2, 13 | "no-undef": 2, 14 | "eqeqeq": 2, 15 | "no-underscore-dangle": 0, 16 | "guard-for-in": 2, 17 | "no-extend-native": 2, 18 | "wrap-iife": 2, 19 | "new-cap": 2, 20 | "no-caller": 2, 21 | "quotes": [1, "single"], 22 | "no-loop-func": 2, 23 | "no-irregular-whitespace": 1, 24 | "no-multi-spaces": 2, 25 | "one-var": [2, "never"], 26 | "no-var": 1, 27 | "strict": [1, "global"], 28 | "no-console": 1, 29 | "semi": 1, 30 | "indent": [2, "tab"], 31 | "no-trailing-spaces": 2 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/middleware/auth/http-basic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const auth = require('basic-auth'); 4 | 5 | function testSuite(req) { 6 | if (process.env.NODE_ENV === 'production') return false; 7 | if (req.cookies.webdriver === '__webdriverTesting__') { 8 | req.cookies.s3o_username = 'selenium.test-user'; 9 | return true; 10 | } 11 | return false; 12 | } 13 | 14 | module.exports = function(req, res, next) { 15 | if (testSuite(req)) next(); 16 | var credentials = auth(req); 17 | if (!credentials || credentials.name !== process.env.AUTH_HTTP_BASIC_NAME || credentials.pass !== process.env.AUTH_HTTP_BASIC_PASS) { 18 | res.statusCode = 401; 19 | res.setHeader('WWW-Authenticate', 'Basic realm="Screen Admin"') 20 | res.end('Access denied'); 21 | } else { 22 | next(); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_rotated-flipped.scss: -------------------------------------------------------------------------------- 1 | // Rotated & Flipped Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-rotate-90 { @include fa-icon-rotate(90deg, 1); } 5 | .#{$fa-css-prefix}-rotate-180 { @include fa-icon-rotate(180deg, 2); } 6 | .#{$fa-css-prefix}-rotate-270 { @include fa-icon-rotate(270deg, 3); } 7 | 8 | .#{$fa-css-prefix}-flip-horizontal { @include fa-icon-flip(-1, 1, 0); } 9 | .#{$fa-css-prefix}-flip-vertical { @include fa-icon-flip(1, -1, 2); } 10 | 11 | // Hook for IE8-9 12 | // ------------------------- 13 | 14 | :root .#{$fa-css-prefix}-rotate-90, 15 | :root .#{$fa-css-prefix}-rotate-180, 16 | :root .#{$fa-css-prefix}-rotate-270, 17 | :root .#{$fa-css-prefix}-flip-horizontal, 18 | :root .#{$fa-css-prefix}-flip-vertical { 19 | filter: none; 20 | } 21 | -------------------------------------------------------------------------------- /server/renderAdminPage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; //eslint-disable-line strict 2 | const screens = require('./screens'); 3 | 4 | module.exports = function renderAdminPage(req, res) { 5 | let title = ''; 6 | 7 | if (req.query.filter) { 8 | const name = screens.get(req.query.filter).map(function(screen) { 9 | return screen.name || screen.id; 10 | })[0]; 11 | 12 | if (name) { 13 | title = name + ' : FT Screens'; 14 | } 15 | } 16 | 17 | res.render('admin', { 18 | app:'admin', 19 | screens: screens.get().sort(function(a,b) { 20 | a = a.name || 'Screen #' + a.id; 21 | b = b.name || 'Screen #' + b.id; 22 | 23 | if(a < b) return -1; 24 | if(a > b) return 1; 25 | return 0; 26 | }), 27 | filter: req.query.filter, 28 | title: title, 29 | redirect: req.query.redirect 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /client/generator-rtc-view/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | /* global SimpleWebRTC*/ 3 | const parseQueryString = require('query-string').parse; 4 | const parameters = parseQueryString(window.location.search); 5 | 6 | const roomID = parameters.room; 7 | const broadcasterID = parameters.id; 8 | 9 | const webrtc = new SimpleWebRTC({ 10 | remoteVideosEl: '', 11 | autoRequestMedia: false 12 | }); 13 | 14 | webrtc.joinRoom(roomID); 15 | 16 | webrtc.on('videoAdded', function (video, peer) { 17 | if (peer.id === broadcasterID) { 18 | const remotes = document.getElementById('remoteVideos'); 19 | const container = document.createElement('div'); 20 | container.appendChild(video); 21 | 22 | // suppress contextmenu 23 | video.oncontextmenu = function () { return false; }; 24 | remotes.appendChild(container); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_path.scss: -------------------------------------------------------------------------------- 1 | /* FONT PATH 2 | * -------------------------- */ 3 | 4 | @font-face { 5 | font-family: 'FontAwesome'; 6 | src: url('#{$fa-font-path}/fontawesome-webfont.eot?v=#{$fa-version}'); 7 | src: url('#{$fa-font-path}/fontawesome-webfont.eot?#iefix&v=#{$fa-version}') format('embedded-opentype'), 8 | url('#{$fa-font-path}/fontawesome-webfont.woff2?v=#{$fa-version}') format('woff2'), 9 | url('#{$fa-font-path}/fontawesome-webfont.woff?v=#{$fa-version}') format('woff'), 10 | url('#{$fa-font-path}/fontawesome-webfont.ttf?v=#{$fa-version}') format('truetype'), 11 | url('#{$fa-font-path}/fontawesome-webfont.svg?v=#{$fa-version}#fontawesomeregular') format('svg'); 12 | // src: url('#{$fa-font-path}/FontAwesome.otf') format('opentype'); // used when developing fonts 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_animated.scss: -------------------------------------------------------------------------------- 1 | // Spinning Icons 2 | // -------------------------- 3 | 4 | .#{$fa-css-prefix}-spin { 5 | -webkit-animation: fa-spin 2s infinite linear; 6 | animation: fa-spin 2s infinite linear; 7 | } 8 | 9 | .#{$fa-css-prefix}-pulse { 10 | -webkit-animation: fa-spin 1s infinite steps(8); 11 | animation: fa-spin 1s infinite steps(8); 12 | } 13 | 14 | @-webkit-keyframes fa-spin { 15 | 0% { 16 | -webkit-transform: rotate(0deg); 17 | transform: rotate(0deg); 18 | } 19 | 100% { 20 | -webkit-transform: rotate(359deg); 21 | transform: rotate(359deg); 22 | } 23 | } 24 | 25 | @keyframes fa-spin { 26 | 0% { 27 | -webkit-transform: rotate(0deg); 28 | transform: rotate(0deg); 29 | } 30 | 100% { 31 | -webkit-transform: rotate(359deg); 32 | transform: rotate(359deg); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/pages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; //eslint-disable-line strict 2 | const cache = require('lru-cache')({ 3 | max: 10*1024*1024, 4 | length: function(n) { return n.length; }, 5 | maxAge: 7*24*60*60*1000 6 | }); 7 | const cheerio = require('cheerio'); 8 | const fetch = require('node-fetch'); 9 | const debug = require('debug')('screens:pages'); 10 | 11 | module.exports = function(url) { 12 | 13 | let $; 14 | let source; 15 | 16 | function getTitle() { 17 | return $ ? $('head title').text() : null; 18 | } 19 | 20 | source = cache.get(url); 21 | if (!source) { 22 | fetch(url) 23 | .then(function(resp) { return resp.text(); }) 24 | .then(function(body) { 25 | debug('Loaded URL '+url+' ('+body.length+'b)'); 26 | cache.set(url, body); 27 | $ = cheerio.load(body); 28 | }) 29 | ; 30 | } else { 31 | $ = cheerio.load(source); 32 | } 33 | 34 | return { 35 | getTitle: getTitle 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /client/admin/js/renamescreens.js: -------------------------------------------------------------------------------- 1 | exports.init = function($) { 2 | 3 | const keys = require('./keycodes'); 4 | const api = require('../../common/js/api'); 5 | 6 | $('.screens') 7 | .on('click', '.action-rename', function() { 8 | $(this).closest('.screen').addClass('rename-mode').find('input').val( 9 | $(this).closest('.screen').find('label').attr('title') 10 | ).focus(); 11 | }) 12 | .on('keyup', '.rename-group input', function(e) { 13 | if (e.keyCode === keys.ESC) { 14 | $('.screen').removeClass('rename-mode'); 15 | } 16 | }) 17 | .on('submit', '.rename-group', function(e) { 18 | const newname = $(this).find('input').val(); 19 | e.preventDefault(); 20 | 21 | // Don't try renaming if it is empty just act like it wasn't submitted. 22 | if (newname === '') { 23 | return; 24 | } 25 | $(this).closest('.screen').removeClass('rename-mode').find('label').attr('title', newname).html(newname); 26 | api('rename', { 27 | screens: $(this).closest('.screen').attr('data-id'), 28 | name: newname 29 | }); 30 | }) 31 | ; 32 | }; 33 | -------------------------------------------------------------------------------- /client/admin/scss/lib/font-awesome/_mixins.scss: -------------------------------------------------------------------------------- 1 | // Mixins 2 | // -------------------------- 3 | 4 | @mixin fa-icon() { 5 | display: inline-block; 6 | font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration 7 | font-size: inherit; // can't have font-size inherit on line above, so need to override 8 | text-rendering: auto; // optimizelegibility throws things off #1094 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | } 13 | 14 | @mixin fa-icon-rotate($degrees, $rotation) { 15 | filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}); 16 | -webkit-transform: rotate($degrees); 17 | -ms-transform: rotate($degrees); 18 | transform: rotate($degrees); 19 | } 20 | 21 | @mixin fa-icon-flip($horiz, $vert, $rotation) { 22 | filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}); 23 | -webkit-transform: scale($horiz, $vert); 24 | -ms-transform: scale($horiz, $vert); 25 | transform: scale($horiz, $vert); 26 | } 27 | -------------------------------------------------------------------------------- /client/admin/js/filter.js: -------------------------------------------------------------------------------- 1 | let $; 2 | let $txt; 3 | 4 | const debounce = require('lodash.debounce'); 5 | 6 | function applyFilters() { 7 | if ($txt.val()) { 8 | const regex = new RegExp('^(.*?)('+$txt.val()+')(.*?)$', 'ig'); 9 | const $matching = $('.screen[data-filter*="'+$txt.val().toLowerCase()+'"]'); 10 | $matching.show().find('.screen-name').each(function() { 11 | $(this).html(this.title.replace(regex, '$1$2$3')); 12 | }); 13 | $('.screen').not($matching).hide(); 14 | if ($matching.length === 1 && !$matching.find('.screen-select').prop('checked')) { 15 | $matching.find('.screen-select').prop('checked', true); 16 | } 17 | } else { 18 | $('.screen').show().find('.screen-name').each(function() { 19 | $(this).html(this.title); 20 | }); 21 | } 22 | } 23 | 24 | const applyFiltersDebounced = debounce(applyFilters, 200, { 25 | leading: true 26 | }); 27 | 28 | exports.init = function(jQuery) { 29 | $ = jQuery; 30 | $txt = $('#txtfilter'); 31 | $txt.on('keyup', function(e) { 32 | e.preventDefault(); 33 | applyFiltersDebounced(); 34 | }); 35 | applyFiltersDebounced(); 36 | }; 37 | 38 | exports.apply = applyFiltersDebounced; 39 | -------------------------------------------------------------------------------- /server/middleware/auth/ft-s3o.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const authS3O = require('s3o-middleware'); 3 | 4 | function testSuite(req) { 5 | if (process.env.NODE_ENV === 'production') return false; 6 | if (req.cookies.webdriver === '__webdriverTesting__') { 7 | req.cookies.s3o_username = 'selenium.test-user'; 8 | return true; 9 | } 10 | return false; 11 | } 12 | 13 | module.exports = function(req, res, next) { 14 | if (testSuite(req) || req.originalUrl.indexOf('/generators/') === 0 && Object.keys(req.query).length !== 0) { 15 | next(); 16 | } else if (req.query.redirect === 'true') { 17 | next(); 18 | } else { 19 | 20 | // since it may be used in a middleware restore original url to the request object. 21 | const oldUrl = req.url; 22 | req.url = req.originalUrl; 23 | authS3O(req, res, function () { 24 | 25 | // restore the old url for routing purposes 26 | req.url = oldUrl; 27 | 28 | // AB: I don't know how this would be encountered, but I moved it from 29 | // api.js to fully abstract auth into backend-specific modules 30 | if (!req.cookies.s3o_username) { 31 | return res.status(403).send('Not logged in.'); 32 | } else { 33 | next(); 34 | } 35 | }); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /runbook.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "name": "screens", 4 | "purpose": "Digital signage solution for displaying webpages and controling them from a central server.", 5 | "audience": "internal", 6 | "primaryUrl": "http://ftlabs-screens.herokuapp.com/", 7 | "serviceTier": "bronze", 8 | "appVersion": "0.0.0", 9 | "apiVersion": 1, 10 | "apiVersions": [ 11 | { "path": "/api", "supportStatus": "active" }, 12 | { "path": "/screens", "supportStatus": "active" } 13 | ], 14 | "dateCreated": "2015-11-02", 15 | "contacts": [ 16 | { "name":"FT Labs team", "email":"ftlabs@ft.com", "rel":"owner", "domain":"All support enquiries" } 17 | ], 18 | "links": [ 19 | {"url": "https://github.com/ftlabs/screens", "category": "repo"}, 20 | {"url": "https://github.com/ftlabs/screens/issues", "category": "issues"}, 21 | {"url": "https://github.com/ftlabs/screens-viewer", "category": "repo"}, 22 | {"url": "https://github.com/ftlabs/screens-carousel", "category": "repo"}, 23 | {"url": "http://git.svc.ft.com/projects/LABSPROT/repos/electron-for-ftlabs-screens/browse", "category": "repo"}, 24 | {"url": "https://github.com/ftlabs/screens/blob/master/README.md", "category": "documentation", "description": "README"}, 25 | {"url": "http://labs.ft.com/2015/09/screens/", "category": "documentation", "description": "write up"} 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tests/healthcheck.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // TODO AE07122015: Once further tests are written encorporate them into the healthcheck 4 | 5 | const path = require('path'); 6 | const Mocha = require('mocha'); 7 | 8 | const mocha = new Mocha(); 9 | mocha.addFile(path.join(__dirname, 'unit/test.js')); 10 | 11 | // Return a promise that resolves to a set of healthchecks 12 | module.exports = function() { 13 | 14 | // You might have several async checks that you need to perform or 15 | // collect the results from, this is a really simplistic example 16 | return new Promise(function(resolve) { 17 | mocha.run(function (failures) { 18 | if (failures === 0) { 19 | resolve([ 20 | { 21 | name: 'URL Transform Tests Passing', 22 | ok: true, 23 | severity: 2, 24 | businessImpact: 'TODO', 25 | technicalSummary: 'TODO', 26 | panicGuide: 'TODO', 27 | checkOutput: 'TODO', 28 | lastUpdated: new Date().toISOString() 29 | } 30 | ]); 31 | } else { 32 | resolve([ 33 | { 34 | name: 'Failing to convert URLs', 35 | ok: false, 36 | severity: 2, 37 | businessImpact: 'TODO', 38 | technicalSummary: 'TODO', 39 | panicGuide: 'TODO', 40 | checkOutput: 'TODO', 41 | lastUpdated: new Date().toISOString() 42 | } 43 | ]); 44 | } 45 | }); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /client/common/scss/_admin-common.scss: -------------------------------------------------------------------------------- 1 | // Output @font-face declarations 2 | $o-fonts-is-silent: false; 3 | $o-forms-is-silent: false; 4 | $o-ft-icons-is-silent: false; 5 | 6 | // Import Origami components 7 | @import 'o-grid/main'; 8 | @import 'o-fonts/main'; 9 | @import 'o-colors/main'; 10 | @import 'o-forms/main'; 11 | @import 'o-buttons/main'; 12 | @import 'o-header/main'; 13 | @import 'o-hierarchical-nav/main'; 14 | @import 'o-ft-icons/main'; 15 | 16 | // Store the default FT sans-serif font stack in a variable 17 | $sans-serif: oFontsGetFontFamilyWithFallbacks(BentonSans); 18 | 19 | html { 20 | // The iconic pink background 21 | @include oColorsFor(page, background); 22 | 23 | // Set a font family on the whole document 24 | font-family: $sans-serif; 25 | 26 | // Prevent navigation menus from creating 27 | // extra space on sides of the page 28 | overflow-x: hidden; 29 | } 30 | 31 | body { 32 | @include oGridRow; 33 | font-family: metricweb; 34 | margin: 0; 35 | padding: 0; 36 | flex-direction: column; 37 | max-width: initial; 38 | } 39 | 40 | .o-header__primary__right.o-header__nav--tools-theme { 41 | overflow: visible; 42 | [data-o-hierarchical-nav-level='1'] > li > a { 43 | width: auto; 44 | font-size: 100%; 45 | } 46 | } 47 | 48 | .page { 49 | @include oGridColumn((default: 12, S: 11)); 50 | @include oGridCenter; 51 | padding: 0 1em; 52 | } 53 | -------------------------------------------------------------------------------- /tests/integration/lib/tabs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function TabsController(client) { 4 | 5 | // set the current tab to the admin page. 6 | const tabs = {}; 7 | let loaded = client; 8 | 9 | class Tab { 10 | constructor(name, options) { 11 | 12 | const url = options.url; 13 | const handle = options.handle; 14 | 15 | if (url) loaded = loaded 16 | .newWindow(url) 17 | .getCurrentTabId() 18 | .then(handle => { 19 | this.handle = handle; 20 | }); 21 | 22 | if (handle) { 23 | this.handle = handle; 24 | } 25 | 26 | this.name = name; 27 | 28 | tabs[name] = this; 29 | } 30 | 31 | ready() { 32 | return loaded 33 | .then(() => this); 34 | } 35 | 36 | switchTo() { 37 | 38 | loaded = loaded 39 | .getCurrentTabId() 40 | .then(id => { 41 | if (id !== this.handle) { 42 | return client.switchTab(this.handle); 43 | } 44 | }); 45 | 46 | return loaded 47 | .then(() => this); 48 | } 49 | 50 | close () { 51 | loaded = this.switchTo() 52 | .window() 53 | .then(() => { 54 | delete tabs[this.name]; 55 | }) 56 | .switchTab(); // go to next tab 57 | return loaded; 58 | } 59 | } 60 | 61 | const single = { 62 | Tab, 63 | tabs 64 | }; 65 | 66 | return single; 67 | }; 68 | 69 | 70 | let tabController; 71 | function getTabController(client) { 72 | if (!tabController) { 73 | tabController = new TabsController(client); 74 | } 75 | return tabController; 76 | } 77 | 78 | module.exports = { 79 | getTabController 80 | }; 81 | -------------------------------------------------------------------------------- /views/partials/screen.handlebars: -------------------------------------------------------------------------------- 1 | 2 | {{{id}}} 3 | 4 | 5 | 6 |
7 | or ESC to cancel 8 |
9 | 10 | 11 | {{#if items}} 12 |
    13 | {{#items}} 14 |
  1. 15 | 16 |

    {{htmltitle url}}

    17 |
    18 |
    19 |
    20 | 21 | {{#if expires}} (expires {{relTime expires}}) {{/if}} 22 |
    23 |
  2. 24 | {{/items}} 25 |
26 | {{else}} 27 | Inactive 28 | {{/if}} 29 | 30 | 31 | -------------------------------------------------------------------------------- /views/generators-home.handlebars: -------------------------------------------------------------------------------- 1 | {{>header}} 2 |
3 |

FT Screens

4 | 5 |

6 | You can add ordinary urls to any screen but in case you want a particular layout or just display a simple text message you can use the tools below. These will produce a URL you can bookmark and use in the screens. 7 |

8 | 9 | 45 |
46 | -------------------------------------------------------------------------------- /views/generators-id-viewer.handlebars: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | 10 | {{hostname}}/{{id}} 11 |
12 |
13 | -------------------------------------------------------------------------------- /client/generator-layout-admin/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser*/ 2 | const output_link = document.querySelector('#output_link'); 3 | const submit = document.querySelector('button'); 4 | 5 | submit.addEventListener('click', function(e) { 6 | const customLayoutForm = document.querySelector('#custom_layout'); 7 | e.preventDefault(); 8 | e.stopPropagation(); 9 | createCustomLayoutPage(customLayoutForm); 10 | }); 11 | 12 | function createCustomLayoutPage() { 13 | const h = document.querySelector('#H'); 14 | const l = document.querySelector('#L'); 15 | const r = document.querySelector('#R'); 16 | const f = document.querySelector('#F'); 17 | 18 | const section_sizes = [ 19 | h.querySelector('[name=height]').value, 20 | l.querySelector('[name=height]').value, 21 | l.querySelector('[name=width]' ).value, 22 | f.querySelector('[name=height]').value 23 | ]; 24 | 25 | const url = '/generators/layout?' + [ 26 | 'title=' + document.querySelector('[name=title]').value, 27 | 'layout=[' + section_sizes.join(',') + ']', 28 | 'layoutNotes=[HeaderHeight,LeftHeight,LeftWidth,FooterHeight]', 29 | 'H=' + encodeURIComponent(h.querySelector('[name=url]').value), 30 | 'L=' + encodeURIComponent(l.querySelector('[name=url]').value), 31 | 'R=' + encodeURIComponent(r.querySelector('[name=url]').value), 32 | 'F=' + encodeURIComponent(f.querySelector('[name=url]').value) 33 | ].join('&'); 34 | 35 | const item = document.createElement('LI'); 36 | const link = document.createElement('a'); 37 | link.href = url; 38 | link.innerHTML = link.href; 39 | item.appendChild(link); 40 | output_link.appendChild(item); 41 | const iframe = document.createElement('iframe'); 42 | iframe.src = url; 43 | item.appendChild(iframe); 44 | 45 | // updateDemo(demo, url); 46 | } 47 | -------------------------------------------------------------------------------- /views/generators-standby-viewer.handlebars: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /views/generators-image-viewer.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 36 | 37 |
38 |

Loading Image...

39 | 40 | 81 | -------------------------------------------------------------------------------- /views/partials/header.handlebars: -------------------------------------------------------------------------------- 1 |
2 |
3 | 31 |
32 |
33 | -------------------------------------------------------------------------------- /tests/unit/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; //eslint-disable-line strict 2 | /* global describe, it, before */ 3 | 4 | const expect = require('chai').expect; 5 | const sinon = require('sinon'); 6 | const mockery = require('mockery'); 7 | 8 | describe('Detecting a Youtube URL and transform it into a generator URL', function(){ 9 | 10 | let requestMock; 11 | const youtubeURLs = { 12 | video : 'https://www.youtube.com/watch?v=IXxZRZxafEQ', 13 | playlist : 'https://www.youtube.com/watch?v=sNhhvQGsMEc&list=PLFs4vir_WsTzcfD7ZE8uO3yX-GCKUk9xZ' 14 | }; 15 | 16 | let transform; 17 | const host = 'ftlabs-screens.herokuapp.com'; 18 | 19 | before(function () { 20 | requestMock = sinon.stub(); 21 | mockery.registerMock('request', requestMock); 22 | mockery.registerAllowable('../../server/urls'); 23 | mockery.enable({ 24 | useCleanCache: true 25 | }); 26 | transform = require('../../server/urls'); 27 | }); 28 | 29 | it('Should detect a Youtube video URL and create a URL to the Youtube generator specifying that it\'s a video', function(done){ 30 | 31 | //^(https?:\/\/[^\/]*(ftlabs-herokuapp.com))?\/generators\/youtube\?mediaURI=*([a-zA-z]{11}|[a-zA-z_-]{34}) 32 | 33 | transform(youtubeURLs.video, host).then(function(url){ 34 | // console.log(url); 35 | expect(url).to.match(/^(https?:\/\/[^\/]*(ftlabs-screens.herokuapp.com))?\/generators\/youtube\?mediaURI=([A-Za-z]{11})\&mediaType=video$/); 36 | done(); 37 | }); 38 | 39 | }); 40 | 41 | it('Should detect a Youtube playlist URI and create a URL to the Youtube generator specifying that it\'s a playlist', function(done){ 42 | 43 | transform(youtubeURLs.playlist, host).then(function(url){ 44 | // console.log(url); 45 | expect(url).to.match(/^(https?:\/\/[^\/]*(ftlabs-screens.herokuapp.com))?\/generators\/youtube\?mediaURI=([-_A-Za-z0-9]{34})\&mediaType=playlist$/); 46 | done(); 47 | }); 48 | 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /client/generator-youtube-player/scss/main.scss: -------------------------------------------------------------------------------- 1 | #yt-player { 2 | position: fixed; 3 | left: 0; 4 | top: 0; 5 | width: 100%; 6 | height: 100%; 7 | } 8 | 9 | #cover_card { 10 | background-color: #fff1e0; 11 | position: fixed; 12 | top: 0; 13 | left: 0; 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | .logo { 19 | width: 36vmax; 20 | height: 36vmax; 21 | background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxsaW5lYXJHcmFkaWVudCBpZD0iYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIxMjcuOTk5IiB4Mj0iMTI3Ljk5OSIgeTI9IjI1NiI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjRkZFOENGIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRkZEREI5Ii8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBmaWxsPSJ1cmwoI2EpIiBkPSJNMCAwaDI1NnYyNTZoLTI1NnoiLz48cGF0aCBmaWxsPSIjMzMzIiBkPSJNNjUuNDcxIDEzMy42MDNjMCAxMi4wODEgMy4yMjUgMTMuNDM5IDE3LjAwMSAxMy45NTJ2NC40MjFoLTU0Ljc1NHYtNC40MjFjMTEuMzkzLS41MTMgMTQuNjIxLTEuODcxIDE0LjYyMS0xMy45NTJ2LTc4Ljg4OGMwLTEyLjA5Mi0zLjIyOS0xMy40NDktMTQuMjgxLTEzLjk1NHYtNC40MjJoOTYuNThsLjY4OCAyNS41MDVoLTQuNzYyYy00LjA4My0xMi45MjQtOC41MDctMTctMzEuMTIzLTE3aC0xNy41MTljLTUuMjYyIDAtNi40NTEgMS4xODMtNi40NTEgNS45NXYzNi41NjVoOC42MjhjMTguMDI5IDAgMjEuOTQyLTMuMjQgMjQuMTUtMTUuMzE1aDQuNDJ2NDAuODE0aC00LjQyYy0yLjM3OC0xMy42MDQtOS4xODQtMTcuMDAyLTI0LjE1LTE3LjAwMmgtOC42Mjh2MzcuNzQ3ek0yMzQuMDA3IDM2LjMzOWgtMTAxLjM0OGwtMi4zNTQgMjUuNTE0aDUuODFjMy43MTMtMTIuNDk3IDkuMTctMTcuMDA4IDIxLjM2OS0xNy4wMDhoMTQuMjg0djg4Ljc1OGMwIDEyLjA4MS0zLjIzIDEzLjQzOS0xNi4zMjMgMTMuOTUydjQuNDIxaDU1Ljc3OXYtNC40MjFjLTEzLjA5Ni0uNTEzLTE2LjMyNy0xLjg3MS0xNi4zMjctMTMuOTUydi04OC43NTloMTQuMjc4YzEyLjIwNiAwIDE3LjY2OSA0LjUxMiAyMS4zNzMgMTcuMDA4aDUuODFsLTIuMzUxLTI1LjUxM3oiLz48L3N2Zz4=); 22 | background-repeat: no-repeat; 23 | background-size: contain; 24 | background-position: center center; 25 | position: absolute; 26 | transform: translate(-50%, -50%); 27 | top: 50%; 28 | left: 50%; 29 | } 30 | 31 | *[data-visible=false] { 32 | display: none; 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "screens", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "./bin/www", 7 | "postinstall": "bower install && gulp build", 8 | "build": "gulp build", 9 | "unittests": "mocha ./tests/unit", 10 | "integrationtests": "wdio", 11 | "test": "npm run unittests" 12 | }, 13 | "dependencies": { 14 | "basic-auth": "^1.0.4", 15 | "body-parser": "^1.12.0", 16 | "bower": "1.4.1", 17 | "chai": "^3.4.1", 18 | "cheerio": "^0.22.0", 19 | "cookie": "^0.2.0", 20 | "cookie-parser": "^1.3.4", 21 | "debug": "^2.1.1", 22 | "denodeify": "^1.2.1", 23 | "dotenv": "^1.2.0", 24 | "express": "^4.12.2", 25 | "express-ftwebservice": "^2.1.2", 26 | "express-handlebars": "^2.0.1", 27 | "fetch-er": "0.0.10", 28 | "ftlabs-screens-carousel": "github:ftlabs/screens-carousel#v1.0.9", 29 | "ftlabs-screens-viewer": "^3.3.0", 30 | "gulp": "latest", 31 | "htmlparser": "^1.7.7", 32 | "image-type": "^2.1.0", 33 | "is-url-superb": "^2.0.0", 34 | "lodash": "^3.10.1", 35 | "lodash.curry": "^3.0.2", 36 | "lodash.debounce": "^4.0.6", 37 | "lru-cache": "^2.6.5", 38 | "mocha": "^2.3.3", 39 | "mockery": "^1.4.0", 40 | "moment": "^2.10.6", 41 | "morgan": "^1.5.1", 42 | "node-fetch": "^1.3.2", 43 | "node-uuid": "^1.4.3", 44 | "origami-build-tools": "^4.4.0", 45 | "query-string": "^2.4.0", 46 | "raven": "^0.9.0", 47 | "redis": "^2.4.2", 48 | "request": "^2.67.0", 49 | "s3o-middleware": "^1.1.0", 50 | "serve-favicon": "^2.2.0", 51 | "showdown": "^1.2.2", 52 | "simplewebrtc": "^1.19.1", 53 | "sinon": "^1.17.2", 54 | "socket.io": "^1.3.7", 55 | "socket.io-client": "^1.3.7" 56 | }, 57 | "engines": { 58 | "node": "6.11.x" 59 | }, 60 | "devDependencies": { 61 | "chai-as-promised": "^5.2.0", 62 | "selenium-standalone": "^4.8.0", 63 | "webdriverio": "^3.3.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /views/generators-ticker-admin.handlebars: -------------------------------------------------------------------------------- 1 | {{>header}} 2 | 3 |
4 |
5 |
6 |

7 |

Generate a ticker

8 |

9 |
10 | 11 |
12 | 13 | 14 |
15 | 16 |

or enter individual messages

17 | 18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 | 37 |
38 |
39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 |
50 | 51 | 52 | 53 |
54 | 55 |
56 |
57 |
58 |
59 |
60 | -------------------------------------------------------------------------------- /views/generators-carousel-admin.handlebars: -------------------------------------------------------------------------------- 1 | {{>header}} 2 | 3 |
4 |
5 |
6 |
7 |

If you would like to edit an existing carousel, 8 | paste the URL in here:

9 |
10 |
11 |
12 |

13 | 14 | A descriptive name for when this carousel is listed in the admin screen) 15 |

16 | 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 37 | 40 | 43 | 44 | 45 |
25 | URL, Remember to include http:// 26 | 27 | Duration (seconds). 28 |
35 | 36 | 38 | 39 | 41 | × 42 |
46 | 47 |
48 | 49 |
50 | 51 |
52 |
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 | 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 |
6 |
7 | 8 | 9 |
10 | 11 |
12 | 13 | 14 | 15 | {{#screens}} 16 | {{>screen}} 17 | {{/screens}} 18 | 19 |
20 |
21 | 29 |
30 | 31 |
32 | 43 | 44 |
45 |
46 |
47 | 48 |
49 |
50 | 51 | 62 | 63 |
64 |
65 | 67 | 68 |
69 |
70 | 71 |
72 | 73 |
74 | 75 |
76 | 77 |
78 |
79 | -------------------------------------------------------------------------------- /views/generators-markdown-view.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 121 | 122 | 123 |
124 | 125 |
126 | 127 |
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 | 40 | 41 | 50 | 51 | 52 | 64 | 72 | 73 | 74 | 83 | 84 |
42 |
43 | Header (full width) 44 | 45 | 46 | 47 | 48 |
49 |
53 |
54 | Left (variable width) 55 | 56 | 57 | 58 | 59 | 60 |
(should be 100 unless you specify a Right url)
61 | 62 |
63 |
65 |
66 | Right 67 | 68 | 69 |

If its url is specified, Right's height will the same as Left, and its width will be 100% - Left's width.

70 |
71 |
75 |
76 | Footer (full width) 77 | 78 | 79 | 80 | 81 |
82 |
85 | 86 |
87 | 88 |
89 |
90 | 91 |
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 |
130 | 131 |
132 | 133 |
134 | 135 |
136 | 137 |
138 | 139 | 140 | 141 | 148 | 149 | 153 | 154 | 155 | 156 |
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 => '' 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 |
309 |
    310 |
    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 | --------------------------------------------------------------------------------