diff --git a/.eslintrc b/.eslintrc
index b87d399..0e3f92e 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,33 +1,88 @@
{
- "parser": "babel-eslint",
- "extends": "airbnb-base",
- "ecmaFeatures": {
- "modules": false
+ "parserOptions": {
+ "ecmaVersion": 9,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "impliedStrict": true
+ }
},
+ "extends": "eslint:recommended",
"plugins": [
"mocha"
],
+ "env": {
+ "mocha": true,
+ "node": true,
+ "es6": true
+ },
"rules": {
"mocha/no-exclusive-tests": 2,
+ "require-await": 0,
+ "no-invalid-this": 0,
+
+ "array-callback-return": 2,
+ "block-scoped-var": 2,
+ "complexity": ["error", 20],
+ "eqeqeq": [2, "smart"],
+ "no-else-return": ["error", { "allowElseIf": false }],
+ "no-extra-bind": 2,
+ "no-implicit-coercion": 2,
+ "no-loop-func": 2,
+ "no-multi-spaces": 2,
+ "no-multi-str": 2,
+ "no-new": 2,
+ "no-param-reassign": [2, {"props": false}],
+ "no-return-assign": 2,
+ "no-return-await": 2,
+ "no-self-compare": 2,
+ "no-sequences": 2,
+ "no-throw-literal": 2,
+ "no-unmodified-loop-condition": 2,
+ "no-useless-call": 2,
+ "no-useless-concat": 2,
+ "no-useless-return": 2,
+ "no-void": 2,
+ "no-warning-comments": 2,
+ "prefer-promise-reject-errors": 2,
+ "no-catch-shadow": 2,
+ "no-shadow": 2,
+ "no-undef-init": 2,
+ "no-undefined": 2,
+ "no-use-before-define": 0,
+ "no-new-require": 2,
+ "no-sync": 2,
+ "array-bracket-newline": [2, "consistent"],
+ "block-spacing": [2, "always"],
+ "brace-style": [2, "1tbs"],
+ "comma-dangle": [2, "always-multiline"],
+ "comma-spacing": 2,
+ "comma-style": 2,
+ "computed-property-spacing": 2,
+ "eol-last": 2,
+ "func-call-spacing": 2,
+ "key-spacing": 2,
+ "keyword-spacing": 2,
+
"semi": [2, "never"],
"max-len": [1, 120],
"prefer-const": 0,
"consistent-return": 0,
- "no-param-reassign": [2, {"props": false}],
- "no-use-before-define": [2, {"functions": false, "classes": true}],
"no-unused-vars": [
2,
{
- "args": "none"
+ "args": "after-used",
+ "argsIgnorePattern": "next|res|req"
}
- ]
+ ],
+ "generator-star-spacing": 0,
+ "global-require": 0,
+ "import/prefer-default-export": 0,
+ "no-underscore-dangle": 0,
+ "strict": 0,
+ "require-yield": 0
},
- "globals": {
- "describe": false,
- "it": false,
- "before": false,
- "beforeEach": false,
- "after": false,
- "afterEach": false
+ globals: {
+ "window": true,
+ "document": true
}
}
diff --git a/api/bookshelf.js b/api/bookshelf.js
index c202fa5..7ff37e2 100644
--- a/api/bookshelf.js
+++ b/api/bookshelf.js
@@ -73,10 +73,11 @@ shelf.createModel = (attr, opts) => {
.fetch({ require, withRelated })
},
- getAll(where = {}, withRelated = []) {
+ getAll(where = {}, withRelated = [], orderBy = 'id') {
where.is_deleted = false
return this.query({ where })
+ .orderBy(orderBy, 'ASC')
.fetchAll({ withRelated })
},
})
diff --git a/api/casparcg/client.js b/api/casparcg/client.js
new file mode 100644
index 0000000..1e10b97
--- /dev/null
+++ b/api/casparcg/client.js
@@ -0,0 +1,120 @@
+import Settings from '../settings/model'
+import { address } from 'ip'
+import { CasparCG, AMCP } from 'casparcg-connection'
+
+const timeoutDuration = 60000
+
+let io
+let logger
+
+let connection
+let casparIsPlaying
+let casparIsConnected
+let checkTimeout
+let currentHost
+
+export async function initialise(log, socket) {
+ io = socket.socket
+ logger = log
+
+ return connect()
+}
+
+export async function connect() {
+ currentHost = await Settings.getValue('casparcg')
+ casparIsPlaying = false
+ casparIsConnected = false
+ logger.info('CasparCG: Connectiong to', currentHost + ':' + 5250)
+
+ if (connection) {
+ await connection.close()
+ }
+
+ connection = new CasparCG({
+ host: currentHost,
+ port: 5250,
+ queueMode: 2,
+ autoReconnectInterval: timeoutDuration,
+ onError: err => {
+ logger.error('CasparCG: Error', err.message)
+ },
+ onConnectionStatus: data => {
+ casparIsConnected = data.connected
+
+ if (!data.connected) {
+ casparIsPlaying = false
+ logger.warn('CasparCG: connection closed, retrying in 60 seconds', connection.connected)
+ io.emit('casparcg.status', currentStatus())
+ if (checkTimeout) clearInterval(checkTimeout)
+ checkTimeout = null
+ }
+ },
+ onConnected: async connected => {
+ logger.info('CasparCG: connected', connected)
+ io.emit('casparcg.status', currentStatus())
+ checkClientPlaying(false, true)
+
+ // Run our check on hourly interval
+ if (checkTimeout) clearInterval(checkTimeout)
+ checkTimeout = setInterval(() => checkClientPlaying(), timeoutDuration * 60)
+ },
+ })
+}
+
+export function currentStatus(e) {
+ return {
+ connected: casparIsConnected,
+ playing: casparIsPlaying,
+ error: e,
+ }
+}
+
+export async function checkClientPlaying(starting = false, first = false) {
+ let ip
+ if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
+ ip = 'localhost'
+ } else {
+ ip = address()
+ }
+
+ // Check if we lost connection while attempting to start playing
+ if (!connection.connected) {
+ logger.error('CasparCG: Attempted to play but connection was lost')
+ }
+
+ try {
+ // Check if we're already playing
+ let output = await connection.info(1, 100)
+
+ if (output.response.data.status !== 'playing') {
+ casparIsPlaying = false
+
+ if (starting) {
+ // We are not playing, check to see if we've already attempted
+ // to issue a play command and delay trying for a minute
+ await new Promise(res => {
+ logger.warn('CasparCG: Play did not start playing, retrying in 60 seconds')
+ setTimeout(res, timeoutDuration)
+ })
+ }
+
+ // Send a play command and retry checking again
+ logger.info(`CasparCG: Sending play command for ${ip}:3000`)
+ await connection.do(new AMCP.CustomCommand(`PLAY 1-100 [HTML] "http://${ip}:3000/client.html" CUT 1 LINEAR RIGHT`))
+ return checkClientPlaying(true)
+ }
+
+ casparIsPlaying = true
+
+ // We are playing, notify all clients
+ io.emit('casparcg.status', currentStatus())
+ if (starting || first) {
+ logger.info('CasparCG: client is up and playing')
+ }
+ } catch (e) {
+ // Unknown error occured
+ casparIsPlaying = true
+ logger.error(e, 'CasparCG: Error starting play on client')
+ io.emit('casparcg.status', currentStatus(e))
+ }
+}
diff --git a/api/casparcg/connection.js b/api/casparcg/connection.js
new file mode 100644
index 0000000..2a93491
--- /dev/null
+++ b/api/casparcg/connection.js
@@ -0,0 +1,5 @@
+import { currentStatus } from './client'
+
+export async function casparConnection(ctx) {
+ ctx.socket.emit('casparcg.status', currentStatus())
+}
diff --git a/api/content/model.js b/api/content/model.js
new file mode 100644
index 0000000..f4c1fd8
--- /dev/null
+++ b/api/content/model.js
@@ -0,0 +1,41 @@
+import bookshelf from '../bookshelf'
+
+/* Content model:
+{
+ id,
+ name,
+ graphic,
+ html,
+ css,
+ data,
+}
+*/
+
+const Content = bookshelf.createModel({
+ tableName: 'content',
+
+ format(attributes) {
+ attributes.graphic = JSON.stringify(attributes.graphic)
+ attributes.data = JSON.stringify(attributes.data)
+ return attributes
+ },
+
+ parse(attributes) {
+ if (attributes.graphic) {
+ attributes.graphic = JSON.parse(attributes.graphic)
+ }
+ if (attributes.data) {
+ attributes.data = JSON.parse(attributes.data)
+ }
+ return attributes
+ },
+}, {
+ getSingle(name, withRelated = [], require = false) {
+ let where = { name }
+
+ return this.query({ where })
+ .fetch({ require, withRelated })
+ },
+})
+
+export default Content
diff --git a/api/content/routes.js b/api/content/routes.js
index d9daf7f..1b447ee 100644
--- a/api/content/routes.js
+++ b/api/content/routes.js
@@ -1,4 +1,5 @@
import _ from 'lodash'
+import Content from './model'
export const active = { }
@@ -7,25 +8,48 @@ function getSocket(ctx, all) {
return ctx.socket
}
-export function display(ctx, data) {
+/*
+ * Event: 'content.display'
+ *
+ * Display a specific graphic content
+ */
+export async function display(ctx, data) {
let compiled = _.template(data.graphic.settings.html)
let html = compiled(data.data)
- let payload = {
- graphic: data.graphic,
- html,
- css: data.graphic.settings.css,
- data: data.data,
+ let old = await Content.getSingle(data.graphic.name)
+
+ if (old) {
+ await old.destroy()
}
- active[data.graphic.name] = payload
- ctx.io.emit('client.display', payload)
+ let payload = {
+ graphic: data.graphic,
+ name: data.graphic.name,
+ html: html || '',
+ css: data.graphic.settings.css || '',
+ data: data.data,
+ is_deleted: false,
+ }
+
+ let content = await Content.create(payload)
+
+ ctx.io.emit('client.display', content.toJSON())
list(ctx, true)
}
-export function hide(ctx, data) {
- delete active[data.name]
+/*
+ * Event: 'content.hide'
+ *
+ * Hide a specific graphic content
+ */
+export async function hide(ctx, data) {
+ let content = await Content.getSingle(data.name)
+
+ if (!content) return
+
+ await content.destroy()
ctx.io.emit('client.hide', {
name: data.name,
@@ -35,21 +59,41 @@ export function hide(ctx, data) {
}
function generateDisplayText(item) {
- if (item.graphic.engine === 'countdown') {
- return `${item.data[item.graphic.settings.main]} - ${item.data.countdown}`
+ // if (item.graphic.engine === 'countdown') {
+ // return `${item.data.text} - ${item.data.finished}`
+ // }
+ try {
+ return _.template(item.graphic.settings.main)(item.data)
+ } catch (e) {
+ return `Error creating display: ${e.message}`
}
- return item.data[item.graphic.settings.main]
}
-export function list(ctx, all) {
- let payload = Object.keys(active).map(key => ({
- name: active[key].graphic.name,
- display: generateDisplayText(active[key]),
- }))
+/*
+ * Event: 'content.list'
+ * Runs on start of every new connection
+ *
+ * Send a name list of all active graphics
+ */
+export async function list(ctx, all) {
+ let allContent = await Content.getAll()
+
+ let payload = await Promise.all(allContent.map(item => ({
+ name: item.get('name'),
+ display: generateDisplayText(item.toJSON()),
+ })))
getSocket(ctx, all).emit('content.list', payload)
}
-export function reset(ctx) {
- ctx.socket.emit('client.reset', _.values(active))
+/*
+ * Event: 'content.list'
+ * Runs on start of every new connection
+ *
+ * Send actual graphics of all active graphics
+ */
+export async function reset(ctx) {
+ let allContent = await Content.getAll()
+
+ ctx.socket.emit('client.reset', allContent.toJSON())
}
diff --git a/api/engine/routes.js b/api/engine/routes.js
index 2adaaaf..ee943b5 100644
--- a/api/engine/routes.js
+++ b/api/engine/routes.js
@@ -1,4 +1,9 @@
+/*
+ * Event: 'engine.all'
+ *
+ * Return all supported graphic engines.
+ */
export function all(ctx) {
- ctx.socket.emit('engine.all', ['text', 'countdown', 'schedule'])
+ ctx.socket.emit('engine.all', ['text', 'countdown'])
}
diff --git a/api/graphic/routes.js b/api/graphic/routes.js
index b3b9e29..fb0008e 100644
--- a/api/graphic/routes.js
+++ b/api/graphic/routes.js
@@ -1,11 +1,21 @@
import Graphic from './model'
+/*
+ * Event: 'graphic.all'
+ *
+ * Request all graphics in store
+ */
export async function all(ctx) {
let data = await Graphic.getAll()
- ctx.io.emit('graphic.all', data.toJSON())
+ ctx.socket.emit('graphic.all', data.toJSON())
}
+/*
+ * Event: 'graphic.single'
+ *
+ * Request a single graphic
+ */
export async function single(ctx, data) {
if (!data || !data.id) {
ctx.log.warn('called graphic get single but no id specified')
@@ -14,23 +24,38 @@ export async function single(ctx, data) {
let graphic = await Graphic.getSingle(data.id)
- ctx.io.emit('graphic.single', graphic.toJSON())
+ ctx.socket.emit('graphic.single', graphic.toJSON())
}
+/*
+ * Event: 'graphic.create'
+ *
+ * Create a single graphic and emit to all clients.
+ *
+ * @body {string} engine - Engine for the graphic
+ * @body {string} name - Name of graphic
+ */
export async function create(ctx, data) {
data.settings = {}
data.is_deleted = false
if (data.engine === 'countdown') {
data.settings.html = `countdown appears here`
- data.settings.main = 'text'
+ data.settings.main = '<%- text %> - <%- finished %>'
}
- await Graphic.create(data)
+ let graphic = await Graphic.create(data)
- await all(ctx)
+ ctx.io.emit('graphic.single', graphic.toJSON())
}
+/*
+ * Event: 'graphic.remove'
+ *
+ * Remove a single graphic
+ *
+ * @body {int} id - Id of the graphic to remove
+ */
export async function remove(ctx, data) {
if (!data || !data.id) {
ctx.log.warn('called graphic get single but no id specified')
@@ -41,9 +66,20 @@ export async function remove(ctx, data) {
graphic.set({ is_deleted: true })
await graphic.save()
- await all(ctx)
+ let output = await Graphic.getAll()
+ ctx.io.emit('graphic.all', output.toJSON())
}
+/*
+ * Event: 'graphic.update'
+ *
+ * Update a single graphic
+ *
+ * @body {int} id - Id of the graphic to update
+ * @body {string} [name] - Name of the graphic
+ * @body {string} [engine] - Engine for the graphic
+ * @body {object} [settings] - Settings for the graphic, JSON object
+ */
export async function update(ctx, data) {
if (!data || !data.id) {
ctx.log.warn('called graphic update but no id specified')
@@ -56,5 +92,5 @@ export async function update(ctx, data) {
await graphic.save()
- await single(ctx, data)
+ ctx.io.emit('graphic.single', graphic.toJSON())
}
diff --git a/api/preset/model.js b/api/preset/model.js
index f4badd0..df6f800 100644
--- a/api/preset/model.js
+++ b/api/preset/model.js
@@ -23,7 +23,7 @@ const Preset = bookshelf.createModel({
attributes.values = JSON.parse(attributes.values)
}
return attributes
- }
+ },
}, {
})
diff --git a/api/preset/routes.js b/api/preset/routes.js
index 72ef031..bf3d9ee 100644
--- a/api/preset/routes.js
+++ b/api/preset/routes.js
@@ -3,7 +3,7 @@ import Preset from './model'
export async function all(ctx, payload) {
let id = Number(payload.graphic_id || payload.id)
- let data = await Preset.getAll({ graphic_id: id })
+ let data = await Preset.getAll({ graphic_id: id }, [], 'sort')
ctx.io.emit(`preset.all:${id}`, data.toJSON())
}
@@ -27,6 +27,18 @@ export async function add(ctx, payload) {
await all(ctx, payload)
}
+export async function patch(ctx, payload) {
+ await Promise.all(payload.map(async item => {
+ let preset = await Preset.getSingle(item.id)
+
+ preset.set({ sort: item.sort })
+
+ await preset.save()
+ }))
+
+ await all(ctx, payload[0])
+}
+
export async function remove(ctx, payload) {
let preset = await Preset.getSingle(payload.id)
diff --git a/api/routerio.js b/api/routerio.js
index 0e14f2f..99f84da 100644
--- a/api/routerio.js
+++ b/api/routerio.js
@@ -1,11 +1,14 @@
import logger from '../log'
import { register } from './io/helper'
import { contentConnection } from './content/connection'
+import { casparConnection } from './casparcg/connection'
import * as content from './content/routes'
import * as engine from './engine/routes'
import * as graphic from './graphic/routes'
import * as preset from './preset/routes'
+import * as settings from './settings/routes'
+import * as schedule from './schedule/routes'
function onConnection(server, data) {
const io = server.socket
@@ -17,11 +20,14 @@ function onConnection(server, data) {
let ctx = { io, socket, log }
contentConnection(ctx)
+ casparConnection(ctx)
register(ctx, 'content', content)
register(ctx, 'engine', engine)
register(ctx, 'graphic', graphic)
register(ctx, 'preset', preset)
+ register(ctx, 'settings', settings)
+ register(ctx, 'schedule', schedule)
}
export default onConnection
diff --git a/api/schedule/model.js b/api/schedule/model.js
new file mode 100644
index 0000000..b4ed2ea
--- /dev/null
+++ b/api/schedule/model.js
@@ -0,0 +1,35 @@
+import bookshelf from '../bookshelf'
+import Graphic from '../graphic/model'
+
+/* Schedule model:
+{
+ id,
+ graphic_id,
+ values,
+ sort,
+ is_deleted,
+}
+*/
+
+const Schedule = bookshelf.createModel({
+ tableName: 'schedule',
+
+ graphic() {
+ return this.belongsTo(Graphic, 'graphic_id')
+ },
+
+ format(attributes) {
+ attributes.values = JSON.stringify(attributes.values)
+ return attributes
+ },
+
+ parse(attributes) {
+ if (attributes.values) {
+ attributes.values = JSON.parse(attributes.values)
+ }
+ return attributes
+ },
+}, {
+})
+
+export default Schedule
diff --git a/api/schedule/routes.js b/api/schedule/routes.js
new file mode 100644
index 0000000..755d7d0
--- /dev/null
+++ b/api/schedule/routes.js
@@ -0,0 +1,52 @@
+import Schedule from './model'
+
+export async function all(ctx) {
+ let data = await Schedule.getAll({ }, ['graphic'], 'sort')
+
+ ctx.io.emit('schedule.all', data.toJSON())
+ total(ctx)
+}
+
+export async function total(ctx) {
+ let data = await Schedule.getAll({ }, ['graphic'], 'sort')
+
+ ctx.io.emit('schedule.total', { total: data.length })
+}
+
+export async function add(ctx, payload) {
+ payload.is_deleted = false
+ payload.sort = 1
+
+ let last = await Schedule.query(q => {
+ q.orderBy('sort', 'desc')
+ q.limit(1)
+ }).fetch({ require: false })
+
+ if (last) {
+ payload.sort = last.get('sort') + 1
+ }
+
+ await Schedule.create(payload)
+
+ await all(ctx)
+}
+
+export async function patch(ctx, payload) {
+ await Promise.all(payload.map(async item => {
+ let scheduleItem = await Schedule.getSingle(item.id)
+
+ scheduleItem.set({ sort: item.sort })
+
+ await scheduleItem.save()
+ }))
+
+ await all(ctx)
+}
+
+export async function remove(ctx, payload) {
+ let scheduleItem = await Schedule.getSingle(payload.id)
+
+ await scheduleItem.destroy()
+
+ await all(ctx)
+}
diff --git a/api/server.js b/api/server.js
index 681a27b..a8884e0 100644
--- a/api/server.js
+++ b/api/server.js
@@ -1,6 +1,7 @@
import Koa from 'koa'
import serve from 'koa-better-serve'
import socket from 'koa-socket'
+import * as casparcg from './casparcg/client'
import config from '../config'
import log from '../log'
@@ -14,9 +15,19 @@ io.attach(app)
io.on('connection', onConnection.bind(this, io))
+casparcg.initialise(log, io).catch(e => {
+ log.error(e, 'Critical error initialising casparcg')
+})
+
app.use(bunyanLogger(log))
app.use(errorHandler())
-app.use(serve('./public', '/public'))
+app.use(async (ctx, next) => {
+ if (ctx.url === '/') {
+ return ctx.redirect('/index.html')
+ }
+ await next()
+})
+app.use(serve('./public', ''))
app.listen(config.get('server:port'), err => {
if (err) return log.critical(err)
diff --git a/api/settings/model.js b/api/settings/model.js
new file mode 100644
index 0000000..84866e3
--- /dev/null
+++ b/api/settings/model.js
@@ -0,0 +1,51 @@
+import bookshelf from '../bookshelf'
+
+/* Settings model:
+{
+ id,
+ name,
+ value,
+}
+*/
+
+const Settings = bookshelf.createModel({
+ tableName: 'settings',
+}, {
+ getValue(name) {
+ return this.query({ where: { name: name } })
+ .fetch({ require: false })
+ .then(item => item && item.get('value') || item)
+ },
+
+ setValue(name, value) {
+ return this.query({ where: { name } })
+ .fetch({ require: false })
+ .then(item => {
+ if (item) {
+ item.set({ value })
+ return item.save()
+ }
+ return this.create({
+ name,
+ value,
+ is_deleted: false,
+ })
+ })
+ },
+
+ getSettings() {
+ return this.query({ where: { }})
+ .fetchAll({ })
+ .then(data => {
+ let out = { }
+
+ data.forEach(item => {
+ out[item.get('name')] = item.get('value')
+ })
+
+ return out
+ })
+ },
+})
+
+export default Settings
diff --git a/api/settings/routes.js b/api/settings/routes.js
new file mode 100644
index 0000000..9edc69f
--- /dev/null
+++ b/api/settings/routes.js
@@ -0,0 +1,37 @@
+import Settings from './model'
+import { connect } from '../casparcg/client'
+
+/*
+ * Event: 'settings.all'
+ *
+ * Request all settings in store
+ */
+export async function all(ctx) {
+ let data = await Settings.getSettings()
+
+ ctx.socket.emit('settings.all', data)
+}
+
+/*
+ * Event: 'settings.update'
+ *
+ * Update a single setting value
+ *
+ * @body {string} [name] - Name of the settings
+ * @body {string} [value] - Value of the settings
+ */
+export async function update(ctx, data) {
+ if (!data || data.name == null || data.value == null) {
+ ctx.log.warn(data, 'called settings update but no name or value specified, body was:')
+ return
+ }
+
+ await Settings.setValue(data.name, data.value)
+
+ let output = await Settings.getSettings()
+ ctx.io.emit('settings.all', output)
+
+ if (data.name === 'casparcg') {
+ connect()
+ }
+}
diff --git a/app/client/countdown.js b/app/client/countdown.js
index 308d2d1..db3dd48 100644
--- a/app/client/countdown.js
+++ b/app/client/countdown.js
@@ -1,5 +1,48 @@
-module.exports = function(data) {
+var currentActiveTimer = null
+
+function pad(n) { return (n < 10) ? ('0' + n) : n }
+
+function timer(name) {
+ var days = 0
+ var hours = 0
+ var mins = 0
+ var secs = 0
+
+ var now = new Date()
+
+ var timeElement = document.getElementById(name + '-countdown-timer')
+
+ if (!timeElement) {
+ clearInterval(currentActiveTimer)
+ currentActiveTimer = null
+ return
+ }
+
+ var data = timeElement.tag
+ var time = data.time
+ var difference = (time - now)
+
+ if (difference <= 0) {
+ clearInterval(currentActiveTimer)
+ currentActiveTimer = null
+ timeElement.innerHTML = data.data.finished || ''
+ return
+ }
+
+ days = Math.floor(difference / (60 * 60 * 1000 * 24) * 1)
+ hours = Math.floor((difference % (60 * 60 * 1000 * 24)) / (60 * 60 * 1000) )
+ mins = Math.floor(((difference % (60 * 60 * 1000 * 24)) % (60 * 60 * 1000)) / (60 * 1000) * 1)
+ secs = Math.floor((((difference % (60 * 60 * 1000 * 24)) % (60 * 60 * 1000)) % (60 * 1000)) / 1000 * 1)
+
+ var text = pad(hours) + ':' + pad(mins) + ':' + pad(secs)
+ if (days > 0) {
+ text = days.toString() + ' dag' + (days > 1 && 'a' || '') + ' ' + text
+ }
+ timeElement.innerHTML = text
+}
+
+module.exports.init = function(data) {
var element = document.createElement('div')
element.innerHTML = data.html
element.id = data.graphic.name
@@ -18,50 +61,19 @@ module.exports = function(data) {
element.classList.add('root-element-display')
}, 100)
+ module.exports.update(data)
+}
+
+module.exports.update = function(data) {
var timeElement = document.getElementById(data.graphic.name + '-countdown-timer')
- var time = new Date(data.data.countdown.replace(' ', 'T'))
-
- function pad(n) { return (n < 10) ? ('0' + n) : n }
-
- function timer() {
- var days = 0
- var hours = 0
- var mins = 0
- var secs = 0
-
- now = new Date()
- difference = (time - now)
-
- timeElement = document.getElementById(data.graphic.name + '-countdown-timer')
-
- if (difference < 0 || !timeElement) {
- clearInterval(data.timer)
- if (timeElement) {
- timeElement.innerHTML = data.data.finished || ''
- }
- return
- }
-
- if (timeElement.tag !== time) {
- clearInterval(data.timer)
- return
- }
-
- days = Math.floor(difference / (60 * 60 * 1000 * 24) * 1);
- hours = Math.floor((difference % (60 * 60 * 1000 * 24)) / (60 * 60 * 1000) );
- mins = Math.floor(((difference % (60 * 60 * 1000 * 24)) % (60 * 60 * 1000)) / (60 * 1000) * 1);
- secs = Math.floor((((difference % (60 * 60 * 1000 * 24)) % (60 * 60 * 1000)) % (60 * 1000)) / 1000 * 1);
-
- var text = pad(hours) + ':' + pad(mins) + ':' + pad(secs);
- if (days > 0) {
- text = days.toString() + ' dag' + (days > 1 && 'a' || '') + ' ' + text;
- }
- timeElement.innerHTML = text
- }
-
+ data.time = new Date(data.data.countdown.replace(' ', 'T'))
+
if (timeElement) {
- timeElement.tag = time
- timer()
- data.timer = setInterval(timer, 1000)
+ timeElement.tag = data
+ timer(data.graphic.name)
+ if (currentActiveTimer) {
+ clearInterval(currentActiveTimer)
+ }
+ currentActiveTimer = setInterval(timer.bind(null, data.graphic.name), 1000)
}
}
diff --git a/app/client/index.js b/app/client/index.js
index 1475879..46f675e 100644
--- a/app/client/index.js
+++ b/app/client/index.js
@@ -6,23 +6,21 @@ var engines = {
schedule: require('./schedule'),
}
-var current = []
-
function display(data) {
var exists = document.getElementById(data.graphic.name)
- if (exists) {
- exists.tag.remove()
- exists.remove()
-
- current.splice(current.indexOf(data.graphic.name), 1)
- }
- current.push(data.graphic.name)
-
var engine = data.graphic.engine
+ if (exists) {
+ exists.innerHtml = data.html
+ exists.tag.innerHtml = data.css
+
+ engines[engine].update(data)
+ return
+ }
+
if (engines[engine]) {
- engines[engine](data)
+ engines[engine].init(data)
}
}
@@ -32,8 +30,6 @@ socket.on('client.hide', function (data) {
var exists = document.getElementById(data.name)
if (exists) {
- current.splice(current.indexOf(data.name), 1)
-
exists.classList.remove('root-element-display')
window.setTimeout(function () {
diff --git a/app/client/schedule.js b/app/client/schedule.js
index 0fb2e92..e478e9f 100644
--- a/app/client/schedule.js
+++ b/app/client/schedule.js
@@ -1,5 +1,5 @@
-module.exports = function(data) {
+module.exports.init = function(data) {
var element = document.createElement('div')
element.innerHTML = data.html
element.id = data.graphic.name
@@ -18,3 +18,7 @@ module.exports = function(data) {
element.classList.add('root-element-display')
}, 100)
}
+
+module.exports.update = function() {
+
+}
diff --git a/app/client/text.js b/app/client/text.js
index 0fb2e92..e478e9f 100644
--- a/app/client/text.js
+++ b/app/client/text.js
@@ -1,5 +1,5 @@
-module.exports = function(data) {
+module.exports.init = function(data) {
var element = document.createElement('div')
element.innerHTML = data.html
element.id = data.graphic.name
@@ -18,3 +18,7 @@ module.exports = function(data) {
element.classList.add('root-element-display')
}, 100)
}
+
+module.exports.update = function() {
+
+}
diff --git a/app/main/add/index.js b/app/main/add/index.js
deleted file mode 100644
index cb39b18..0000000
--- a/app/main/add/index.js
+++ /dev/null
@@ -1,52 +0,0 @@
-const m = require('mithril')
-const createModule = require('./module')
-const components = require('./components')
-const socket = require('../socket')
-
-const Add = createModule({
- init: function() {
- this.monitor('engines', 'engine.all', [])
- this.graphic = { }
- },
-
- updated: function(name, control) {
- this.graphic[name] = control.target.value
- },
-
- create: function() {
- if (!Add.vm.graphic.engine) {
- Add.vm.graphic.engine = Add.vm.engines[0]
- }
- if (!Add.vm.graphic.name) {
- this.error = 'Name cannot be empty'
- return
- }
-
- socket.emit('graphic.create', Add.vm.graphic)
- },
-}, function(ctrl) {
- return m('div', [
- m('h3.container-header', 'Add graphics'),
- m('div.container-panel.panel-add', [
- components.error(Add.vm.error),
- m('label', [
- 'Name',
- m('input[type=text]', {
- oninput: Add.vm.updated.bind(Add.vm, 'name'),
- })
- ]),
- m('label', [
- 'Engine',
- m('select', {
- onchange: Add.vm.updated.bind(Add.vm, 'engine'),
- }, Add.vm.engines.map(engine =>
- m('option', { key: engine, value: engine }, engine)
- ))
- ]),
- m('a.button', {
- onclick: Add.vm.create.bind(Add.vm)
- }, 'Create'),
- ]),
- ])
-})
-module.exports = Add
diff --git a/app/main/add/module.js b/app/main/add/module.js
new file mode 100644
index 0000000..1854d5c
--- /dev/null
+++ b/app/main/add/module.js
@@ -0,0 +1,57 @@
+const m = require('mithril')
+const createModule = require('../common/module')
+const components = require('../common/components')
+const socket = require('../../socket')
+const store = require('../store')
+
+const Add = createModule({
+ init: function() {
+ this.monitor('engines', 'engine.all', [])
+ store.listen('graphic.single', data => {
+ if (data.name === this.graphic.name) {
+ m.route.set(`/graphic/${data.id}`)
+ }
+ })
+ this.graphic = { }
+ },
+
+ updated: function(name, control) {
+ this.graphic[name] = control.target.value
+ },
+
+ create: function() {
+ if (!this.graphic.engine) {
+ this.graphic.engine = this.engines[0]
+ }
+ if (!this.graphic.name) {
+ this.error = 'Name cannot be empty'
+ return
+ }
+
+ socket.emit('graphic.create', this.graphic)
+ },
+
+ removing: function() {
+ store.unlisten('graphic.single')
+ },
+}, function() {
+ return [
+ m('h4.header', 'Add graphic'),
+ components.error(this.error),
+ m('label', { for: 'create-name' }, 'Name'),
+ m('input#create-name[type=text]', {
+ oninput: (control) => this.updated('name', control),
+ }),
+ m('label', { for: 'create-engine' }, 'Engine'),
+ m('select', {
+ onchange: (control) => this.updated('engine', control),
+ }, this.engines.map(engine =>
+ m('option', { key: engine, value: engine }, engine)
+ )),
+ m('input[type=submit]', {
+ value: 'Create',
+ onclick: () => this.create(),
+ }),
+ ]
+})
+module.exports = Add
diff --git a/app/main/common/components.js b/app/main/common/components.js
new file mode 100644
index 0000000..ef2b4eb
--- /dev/null
+++ b/app/main/common/components.js
@@ -0,0 +1,60 @@
+const m = require('mithril')
+
+exports.error = function(error) {
+ if (!error) return null
+
+ return m('div.error-box', error)
+}
+
+exports.presetOnlyList = function(module, graphic, title, color = 'green', button = 'Display now', schedule = 'Schedule') {
+ return [
+ m('label.graphic-label', { key: 'first' }, title),
+ m('div.graphic-presetlist', {
+ key: `second-${graphic.id}`,
+ oncreate: control => module.presetlistInit(control),
+ },
+ module.presets.map(item =>
+ m('div.graphic-preset', {
+ key: `preset-${graphic.id}-${item.id}`,
+ data: item.id,
+ }, [
+ m('div.graphic-preset-reorder'),
+ m('input[type=text]', {
+ readonly: true,
+ value: module.mainTemplate(item.values),
+ }),
+ schedule && m(`button`, {
+ onclick: module.schedulePreset.bind(module, item),
+ }, schedule) || null,
+ m(`button.${color}`, {
+ onclick: module.displayPreset.bind(module, item),
+ }, button),
+ module.displayRemove && m('button.red', {
+ onclick: module.removePreset.bind(module, item),
+ }, 'Remove') || null,
+ ]),
+ ),
+ ),
+ module.presets.length &&
+ m('button.red.graphic-presetremove', {
+ key: 'third',
+ onclick: () => (module.displayRemove = !module.displayRemove),
+ }, 'Remove entries') || null,
+ ]
+}
+
+exports.presetButtons = function(module, green, blue) {
+ return [
+ m('div.graphic-presetadd-buttons', [
+ green && m('button.green', {
+ onclick: module.displayCurrent.bind(module),
+ }, green) || null,
+ blue && m('button', {
+ onclick: module.addPreset.bind(module),
+ }, blue) || null,
+ m('button', {
+ onclick: module.scheduleCurrent.bind(module),
+ }, 'Add to schedule'),
+ ]),
+ ]
+}
diff --git a/app/main/common/module.js b/app/main/common/module.js
new file mode 100644
index 0000000..a2788a1
--- /dev/null
+++ b/app/main/common/module.js
@@ -0,0 +1,98 @@
+const m = require('mithril')
+const _ = require('lodash')
+const store = require('../store')
+const socket = require('../../socket')
+const dragula = require('dragula')
+
+function createModule(component, view) {
+ let newModule = { }
+
+ newModule = _.defaults(component, {
+ oninit: function(vnode) {
+ this.error = null
+ this.listening = []
+ this.init(vnode)
+ },
+
+ _storeUpdated: function(key, name, id, cb) {
+ this[key] = store.get(name, id)
+ if (cb) cb(store.get(name, id))
+ m.redraw()
+ },
+
+ init: function() { },
+
+ removing: function() { },
+
+ monitor: function(key, name, fallback, id, cb) {
+ this[key] = store.get(name, id) || fallback || { }
+
+ this.listening.push(store.getId(name, id))
+
+ store.listen(name, this._storeUpdated.bind(this, key, name, id, cb), id)
+
+ socket.emit(name, { id: id })
+ },
+
+ unmonitor: function(name, id) {
+ store.unlisten(store.getId(name, id))
+ this.listening.splice(this.listening.indexOf(store.getId(name, id)), 1)
+ },
+
+ initDragula: function(control, cb) {
+ let dragContainer = document.getElementById('dragcontainer')
+ let out = dragula([control.dom], {
+ mirrorContainer: dragContainer,
+ invalid: el => el.className !== 'graphic-preset-reorder'
+ && el.className !== 'graphic-preset',
+ })
+ out.on('dragend', () => {
+ if (is_touch_device()) {
+ document.body.style.cssText = ''
+ window.scroll(0, document.body.data)
+ }
+ })
+ out.on('drag', () => {
+ if (is_touch_device()) {
+ document.body.data = window.scrollY
+ document.body.style.cssText = `position: fixed; left: 0; right: 0; overflow: hidden; top: -${window.scrollY}px;`
+ dragContainer.style.marginTop = `${document.body.data}px`
+ }
+ })
+ out.on('drop', (a, b, c, d) => {
+ cb(a, d)
+ })
+ },
+
+ onremove: function() {
+ this.listening.forEach((item) => {
+ store.unlisten(item)
+ })
+ this.removing()
+ },
+
+ view: view,
+ })
+
+ return newModule
+}
+
+// https://stackoverflow.com/questions/4817029/whats-the-best-way-to-detect-a-touch-screen-device-using-javascript/4819886#4819886
+// LOL
+function is_touch_device() {
+ var prefixes = ' -webkit- -moz- -o- -ms- '.split(' ')
+ var mq = function(query) {
+ return window.matchMedia(query).matches
+ }
+
+ if (('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch) {
+ return true
+ }
+
+ // include the 'heartz' as a way to have a non matching MQ to help terminate the join
+ // https://git.io/vznFH
+ var query = ['(', prefixes.join('touch-enabled),('), 'heartz', ')'].join('')
+ return mq(query)
+}
+
+module.exports = createModule
diff --git a/app/main/controller/components.js b/app/main/controller/components.js
deleted file mode 100644
index eeb5e80..0000000
--- a/app/main/controller/components.js
+++ /dev/null
@@ -1,80 +0,0 @@
-const m = require('mithril')
-
-exports.error = function(error) {
- if (!error) return null
-
- return m('div.error-box', error)
-}
-
-exports.presetOnlyList = function(vm) {
- return [
- m('label', 'Presets'),
- m('ul.panel-graphic-preset', vm.presets.map((item, index) =>
- m('li', { key: index }, [
- m('.row', { key: index }, [
- m('div', { class: 'small-8 columns panel-graphic-property-item' },
- m('input[type=text]', {
- readonly: true,
- value: item.values[graphic.settings.main],
- })
- ),
- m('div', { class: 'small-4 columns' },
- [
- m('a.panel-graphic-preset-remove.button.success', {
- onclick: vm.displayPreset.bind(vm, item),
- }, 'Display'),
- m('a.panel-graphic-preset-remove.button.alert', {
- onclick: vm.removePreset.bind(vm, item),
- }, 'X')
- ]
- ),
- ])
- ])
- ))
- ]
-}
-
-exports.presetButtons = function(vm) {
- return [
- m('a.panel-graphic-preset-add.button', {
- onclick: vm.addPreset.bind(vm),
- }, 'Save Preset'),
- m('a.panel-graphic-display.success.button', {
- onclick: vm.displayCurrent.bind(vm),
- }, 'Display Live'),
- ]
-}
-
-exports.presetList = function(vm) {
- return [
- m('a.panel-graphic-preset-add.button', {
- onclick: vm.addPreset.bind(vm),
- }, 'Save Preset'),
- m('a.panel-graphic-display.success.button', {
- onclick: vm.displayCurrent.bind(vm),
- }, 'Display Live'),
- m('label', 'Presets'),
- m('ul.panel-graphic-preset', vm.presets.map((item, index) =>
- m('li', { key: index }, [
- m('.row', { key: index }, [
- m('div', { class: 'small-8 columns panel-graphic-property-item' },
- m('input[type=text]', {
- readonly: true,
- value: item.values[graphic.settings.main],
- })
- ),
- m('div', { class: 'small-4 columns' },
- [
- m('a.panel-graphic-preset-remove.button.success', {
- onclick: vm.displayPreset.bind(vm, item),
- }, 'Display'),
- m('a.panel-graphic-preset-remove.button.alert', {
- onclick: vm.removePreset.bind(vm, item),
- }, 'X')
- ]
- ),
- ])
- ])
- ))
- ]
-}
diff --git a/app/main/controller/module.js b/app/main/controller/module.js
deleted file mode 100644
index b9e9aef..0000000
--- a/app/main/controller/module.js
+++ /dev/null
@@ -1,51 +0,0 @@
-const m = require('mithril')
-const _ = require('lodash')
-const store = require('./store')
-const socket = require('../socket')
-
-function createModule(vm, view) {
- let newModule = { }
- let listening = []
-
- newModule.vm = _.defaults(vm, {
- _init: function() {
- this.error = null
- newModule.vm.init()
- },
-
- _storeUpdated: function(key, name, id) {
- this[key] = store.get(name, id)
- m.redraw()
- },
-
- init: function() { },
-
- monitor: function(key, name, fallback, id) {
- this[key] = store.get(name, id) || fallback || { }
-
- listening.push(name)
-
- store.listen(name, this._storeUpdated.bind(this, key, name, id), id)
-
- socket.emit(name, { id: id })
- },
-
- onunload: function() {
- listening.forEach((item) => {
- store.unlisten(item)
- })
- },
- })
-
- newModule.controller = function() {
- newModule.vm._init()
-
- this.onunload = newModule.vm.onunload
- }
-
- newModule.view = view
-
- return newModule
-}
-
-module.exports = createModule
diff --git a/app/main/dagskra/module.js b/app/main/dagskra/module.js
new file mode 100644
index 0000000..dee6442
--- /dev/null
+++ b/app/main/dagskra/module.js
@@ -0,0 +1,83 @@
+const _ = require('lodash')
+const m = require('mithril')
+const createModule = require('../common/module')
+const socket = require('../../socket')
+
+const Dagskra = createModule({
+ init: function() {
+ this.error = ''
+ this.displayRemove = false
+ this.monitor('schedule', 'schedule.all', [])
+ },
+
+ scheduleListInit: function(control) {
+ this.initDragula(control, (source, target) => {
+ let dragOldIndex = _.findIndex(this.schedule, { id: Number(source.getAttribute('data')) })
+ let targetOldIndex = this.schedule.length - 1
+ if (target) {
+ targetOldIndex = _.findIndex(this.schedule, { id: Number(target.getAttribute('data')) })
+ }
+
+ this.schedule.splice(targetOldIndex, 0, this.schedule.splice(dragOldIndex, 1)[0])
+
+ this.schedule.forEach((item, i) => {
+ item.sort = i + 1
+ })
+
+ socket.emit('schedule.patch', this.schedule)
+ })
+ },
+
+ displaySchedule: function(item) {
+ socket.emit('content.display', {
+ graphic: item.graphic,
+ data: item.values,
+ })
+ },
+
+ removeSchedule: function(item) {
+ socket.emit('schedule.remove', item)
+ },
+}, function() {
+ this.schedule.forEach(item => {
+ if (!item.cachedDisplay) {
+ try {
+ item.cachedDisplay = _.template(item.graphic.settings.main || '')(item.values)
+ } catch (e) {
+ item.cachedDisplay = `ERROR WITH TEMPLATE: ${e.message}`
+ }
+ item.cachedDisplay = `[${item.graphic.name}] ${item.cachedDisplay}`
+ }
+ })
+ return [
+ m('h4.header', 'Schedule'),
+ m('div.graphic-presetlist', {
+ oncreate: control => this.scheduleListInit(control),
+ },
+ this.schedule.map(item =>
+ m('div.graphic-preset', {
+ key: `preset-${item.id}`,
+ data: item.id,
+ }, [
+ m('div.graphic-preset-reorder'),
+ m('input[type=text]', {
+ readonly: true,
+ value: item.cachedDisplay,
+ }),
+ m(`button.green`, {
+ onclick: () => this.displaySchedule(item),
+ }, 'Display'),
+ this.displayRemove && m('button.red', {
+ onclick: () => this.removeSchedule(item),
+ }, 'Remove') || null,
+ ]),
+ ),
+ ),
+ this.schedule.length
+ && m('button.red.graphic-presetremove', {
+ onclick: () => (this.displayRemove = !this.displayRemove),
+ }, 'Remove entries')
+ || m('div.schedule-empty', 'Schedule is empty'),
+ ]
+})
+module.exports = Dagskra
diff --git a/app/main/graphic/controller.js b/app/main/graphic/controller.js
deleted file mode 100644
index ebaf9ad..0000000
--- a/app/main/graphic/controller.js
+++ /dev/null
@@ -1,158 +0,0 @@
-const _ = require('lodash')
-const m = require('mithril')
-const createModule = require('../module')
-const socket = require('../../socket')
-
-const Graphic = createModule({
- init: function() {
- this.monitor('graphic', 'graphic.single', {}, m.route.param('id'))
- this.monitor('presets', 'preset.all', [], m.route.param('id'))
-
- this.currentView = 'view'
- this.current = {}
- this.newProperty = m.prop('')
- this.newTextField = m.prop('')
- },
-
- updated: function(name, variable, control) {
- if (!control) {
- control = variable
- variable = 'graphic'
- }
- _.set(this[variable], name, control.target.value)
-
- if (variable === 'graphic') {
- socket.emit('graphic.update', this.graphic)
- }
- },
-
- addDataField: function(type, name) {
- if (!name) {
- return 'Please type in proper name'
- }
-
- if (this.graphic.settings[type].includes(name)) {
- return 'A property with that name already exists'
- }
-
- this.graphic.settings[type].push(name)
-
- socket.emit('graphic.update', this.graphic)
-
- return null
- },
-
- addProperty: function() {
- this.error = this.addDataField('properties', this.newProperty())
-
- if (!this.error) {
- this.newProperty('')
-
- if (!this.graphic.settings.main) {
- this.graphic.settings.main = this.graphic.settings.properties[0]
- socket.emit('graphic.update', this.graphic)
- }
- }
- },
-
- addTextField: function() {
- this.error = this.addDataField('textfields', this.newTextField())
-
- if (!this.error) {
- this.newTextField('')
- }
- },
-
- removeDataField: function(type, name) {
- this.graphic.settings[type].splice(
- this.graphic.settings[type].indexOf(name), 1)
- socket.emit('graphic.update', this.graphic)
- },
-
- removeProperty: function(prop) {
- this.removeDataField('properties', prop)
- },
-
- cleanCurrent: function() {
- if (this.graphic.engine === 'countdown') {
- this.current.text = this.graphic.settings.text
- this.current.countdown = this.graphic.settings.countdown
- this.current.finished = this.graphic.settings.finished
-
- if (!this.current.countdown) {
- this.error = 'Count to had to be defined'
- }
- else {
- let test = new Date(this.current.countdown.replace(' ', 'T'))
- if (!test.getTime()) {
- this.error = 'Count to has to be valid date and time'
- }
- }
- } else {
- this.graphic.settings.properties.forEach(prop => {
- if (!this.current[prop]) {
- this.current[prop] = ''
- }
- })
- }
- if (this.graphic.settings.main &&
- !this.current[this.graphic.settings.main]) {
- this.error = `Property "${this.graphic.settings.main}" cannot be empty`
- return
- }
- },
-
- addPreset: function() {
- this.error = ''
-
- this.cleanCurrent()
-
- if (this.error) return
-
- socket.emit('preset.add', {
- graphic_id: this.graphic.id,
- values: this.current,
- })
- },
-
- removePreset: function(preset) {
- socket.emit('preset.remove', preset)
- },
-
- remove: function() {
- socket.emit('graphic.remove', this.graphic)
- m.route('/')
- },
-
- displayPreset: function(preset) {
- socket.emit('content.display', {
- graphic: this.graphic,
- data: preset.values,
- })
- },
-
- displayCurrent: function() {
- this.error = ''
-
- this.cleanCurrent()
-
- if (this.error) return
-
- socket.emit('content.display', {
- graphic: this.graphic,
- data: this.current,
- })
- },
-
- switchView: function() {
- if (Graphic.vm.currentView === 'view') {
- Graphic.vm.currentView = 'settings'
- return
- }
- Graphic.vm.currentView = 'view'
- },
-})
-
-module.exports = Graphic
-
-require('./view')
diff --git a/app/main/graphic/engine/countdown.js b/app/main/graphic/engine/countdown.js
index 96691a5..63485b1 100644
--- a/app/main/graphic/engine/countdown.js
+++ b/app/main/graphic/engine/countdown.js
@@ -1,65 +1,82 @@
const m = require('mithril')
-const components = require('../../components')
+const components = require('../../common/components')
-exports.view = function(ctlr, graphic, vm) {
+exports.view = function(module, graphic) {
return [
- m('label', [
- 'Text',
- m('input[type=text]', {
- value: vm.graphic.settings.text || '',
- oninput: vm.updated.bind(vm, 'settings.text'),
+ m('div.graphic-presetadd', [
+ m('h3.graphic-presetadd-header', 'Start countdown'),
+
+ m('label', { for: `countdown-text` }, 'Text'),
+ m(`input#countdown-text[type=text]`, {
+ value: graphic.settings.text || '',
+ oninput: module.updated.bind(module, 'settings.text'),
}),
- ]),
- m('label', [
- 'Count to (format: "YYYY-MM-DD hh:mm")',
- m('input[type=text]', {
- value: vm.graphic.settings.countdown || '',
- oninput: vm.updated.bind(vm, 'settings.countdown'),
+
+ m('label', { for: `countdown-countdown` }, 'Count to (format: "YYYY-MM-DD hh:mm")'),
+ m(`input#countdown-countdown[type=text]`, {
+ value: graphic.settings.countdown || '',
+ oninput: module.updated.bind(module, 'settings.countdown'),
}),
- ]),
- m('label', [
- 'Finished (gets displayed in the countdown upon reaching 0)',
- m('input[type=text]', {
- value: vm.graphic.settings.finished || '',
- oninput: vm.updated.bind(vm, 'settings.finished'),
+
+ m('label', { for: `countdown-finished` }, 'Finished (gets displayed in the countdown upon reaching 0)'),
+ m(`input#countdown-finished[type=text]`, {
+ value: graphic.settings.finished || '',
+ oninput: module.updated.bind(module, 'settings.finished'),
}),
+ components.presetButtons(module, 'Display live now', 'Add to template'),
]),
- components.presetList(vm),
+ components.presetOnlyList(module, graphic, 'Templates', '', 'Fill top', ''),
]
}
-exports.settings = function(cltr, graphic, vm) {
+exports.settings = function(module, graphic) {
return [
- m('label', [
- 'Name',
- m('input[type=text]', {
- value: graphic.name,
- oninput: vm.updated.bind(vm, 'name'),
- }),
+ // Name
+ m('label.graphic-label', { for: 'graphic-name' }, 'Graphic ID'),
+ m('input#graphic-name[type=text]', {
+ value: graphic.name,
+ oninput: module.updated.bind(module, 'name'),
+ }),
+
+ // HTML
+ m('label.graphic-label', { for: 'graphic-html' }, [
+ 'Graphic HTML (',
+ m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
+ ' available: <%- text %>)',
]),
- m('label', [
- 'HTML (',
- m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
- ' available: <%- text %>',
- ')',
- m('p', `
`),
- m('textarea', {
- rows: '4',
- oninput: vm.updated.bind(null, 'settings.html'),
- value: graphic.settings.html || '',
- }),
- m('p', `
`),
+ m('p.graphic-helper', ``),
+ m('textarea#graphic-html', {
+ rows: '4',
+ oninput: module.updated.bind(module, 'settings.html'),
+ value: graphic.settings.html || '',
+ }),
+ m('p.graphic-helper.bottom', `
`),
+
+ // CSS
+ m('label.graphic-label', { for: 'graphic-css' }, 'Graphic CSS'),
+ m('p.graphic-helper', ''),
+
+ // Main display template
+ m('label.graphic-label', { for: 'graphic-main' }, [
+ 'Graphic control display template (',
+ m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
+ ' available: <%- text %>, <%- countdown %>, <%- finished %>)',
]),
- m('label', [
- 'CSS',
- m('textarea', {
- rows: '4',
- oninput: vm.updated.bind(null, 'settings.css'),
- value: graphic.settings.css || '',
- })
- ]),
- m('a.panel-graphic-delete.button.alert', {
- onclick: vm.remove.bind(vm),
+ m('input#graphic-main[type=text]', {
+ value: graphic.settings.main,
+ oninput: module.updated.bind(module, 'settings.main'),
+ }),
+ components.error(module.mainTemplateError),
+
+ // Remove
+ m('button.red.graphic-delete', {
+ onclick: module.remove.bind(module),
}, 'Delete graphic'),
]
}
diff --git a/app/main/graphic/engine/schedule.js b/app/main/graphic/engine/schedule.js
index d0dc583..263d7c9 100644
--- a/app/main/graphic/engine/schedule.js
+++ b/app/main/graphic/engine/schedule.js
@@ -1,162 +1,121 @@
const m = require('mithril')
-const components = require('../../components')
+const components = require('../../common/components')
-exports.view = function(ctlr, graphic, vm) {
+exports.view = function(module, graphic) {
if (!graphic.settings.properties) {
graphic.settings.properties = []
}
- if (!graphic.settings.textfields) {
- graphic.settings.textfields = []
- }
if (graphic.settings.properties.length === 0) {
return [
- m('p', 'No properties have been defined.'),
- m('p', 'Click settings to create and define properties to display.'),
+ m('p.settings-empty', `
+ No properties have been defined.
+ This graphic needs properties to be defined before usage.
+ Click the settings button to define the properties for this graphic.
+ `),
+ m('button.settings-empty-button', {
+ onclick: () => module.switchView(),
+ }, module.changeViewTitle()),
]
}
return [
- components.presetOnlyList(vm),
- graphic.settings.properties.map((prop, index) =>
- m('label', { key: index }, [
- prop,
- m('input[type=text]', {
- value: vm.current[prop] || '',
- oninput: vm.updated.bind(vm, prop, 'current'),
+ m('div.graphic-presetadd', [
+ m('h3.graphic-presetadd-header', 'Create preset/display graphic'),
+ graphic.settings.properties.map((prop, index) => m.fragment({ key: `prop-${index}` }, [
+ m('label', { for: `preset-add-${index}` }, prop),
+ m(`input#preset-add-${index}[type=text]`, {
+ value: module.current[prop] || '',
+ oninput: module.updated.bind(module, prop, 'current'),
}),
- ])
- ),
- graphic.settings.textfields.map((prop, index) =>
- m('label', { key: index }, [
- prop,
- m('textarea', {
- rows: '6',
- oninput: vm.updated.bind(vm, prop, 'current'),
- value: vm.current[prop] || '',
- }),
- ])
- ),
- components.presetButtons(vm),
+ ])),
+ components.presetButtons(module, 'Display live now', 'Add to preset list'),
+ ]),
+ components.presetOnlyList(module, graphic, 'Presets'),
]
}
-exports.settings = function(cltr, graphic, vm) {
+exports.settings = function(module, graphic) {
return [
- m('label', [
- 'Name',
- m('input[type=text]', {
- value: graphic.name,
- oninput: vm.updated.bind(vm, 'name'),
- }),
+ // Name
+ m('label.graphic-label', { for: 'graphic-name' }, 'Graphic ID'),
+ m('input#graphic-name[type=text]', {
+ value: graphic.name,
+ oninput: module.updated.bind(module, 'name'),
+ }),
+
+ // HTML
+ m('label.graphic-label', { for: 'graphic-html' }, [
+ 'Graphic HTML (',
+ m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
+ ' available: ',
+ graphic.settings.properties.map(prop =>
+ `<%- ${prop} %>`
+ ).join(', '),
+ ')',
]),
- m('label', [
- 'HTML (',
- m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
- ' available: ',
- graphic.settings.properties.map(prop =>
- `<%- ${prop} %>`
- ).join(', '),
- ', ',
- graphic.settings.textfields.map(prop =>
- `<%- ${prop} %>`
- ).join(', '),
- ')',
- m('p', ``),
- m('textarea', {
- rows: '4',
- oninput: vm.updated.bind(null, 'settings.html'),
- value: graphic.settings.html || '',
- }),
- m('p', `
`),
+ m('p.graphic-helper', ``),
+ m('textarea#graphic-html', {
+ rows: '4',
+ oninput: module.updated.bind(null, 'settings.html'),
+ value: graphic.settings.html || '',
+ }),
+ m('p.graphic-helper.bottom', `
`),
+
+ // CSS
+ m('label.graphic-label', { for: 'graphic-css' }, 'Graphic CSS'),
+ m('p.graphic-helper', ''),
+
+ // Main display template
+ m('label.graphic-label', { for: 'graphic-main' }, [
+ 'Graphic control display template (',
+ m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
+ ' available: ',
+ graphic.settings.properties.map(prop =>
+ `<%- ${prop} %>`
+ ).join(', '),
+ ')',
]),
- m('label', [
- 'CSS',
- m('textarea', {
- rows: '4',
- oninput: vm.updated.bind(null, 'settings.css'),
- value: graphic.settings.css || '',
- })
- ]),
- /* -------- Simple Properties -------- */
- m('label', 'Simple Properties'),
- m('label', [
- 'Main',
- m('select', {
- onchange: vm.updated.bind(vm, 'settings.main'),
- }, graphic.settings.properties.map((prop, index) =>
- m('option', {
- key: 'prop-list-' + index,
+ m('input#graphic-main[type=text]', {
+ value: graphic.settings.main,
+ oninput: module.updated.bind(module, 'settings.main'),
+ }),
+
+ // Property list
+ m('label.graphic-label', 'Properties'),
+ graphic.settings.properties.map((prop, index) =>
+ m('div.graphic-property', { key: `prop-${index}` }, [
+ m('input[type=text]', {
+ readonly: true,
value: prop,
- selected: prop === graphic.settings.main,
- }, prop)
- ))
+ }),
+ m('button.red', {
+ onclick: module.removeProperty.bind(module, prop),
+ }, 'Remove'),
+ ]),
+ ),
+ graphic.settings.properties.length === 0 && m('p.graphic-empty', 'No properties exist yet.') || [],
+
+ // Add a new property
+ m('label.graphic-label', { for: 'graphic-newproperty' }, 'Add new graphic property'),
+ m('div.graphic-property', [
+ m('input#graphic-newproperty[type=text]', {
+ value: module.newProperty,
+ oninput: m.withAttr('value', val => (module.newProperty = val)),
+ }),
+ m('button', {
+ onclick: module.addProperty.bind(module),
+ }, 'Add'),
]),
- /* -------- Simple Properties List -------- */
- m('label', 'List'),
- m('div', [
- graphic.settings.properties.map((prop, index) =>
- m('.row', { key: 'add-prop-' + index }, [
- m('div', { class: 'small-10 columns panel-graphic-property-item' },
- m('input[type=text]', {
- readonly: true,
- value: prop,
- })
- ),
- m('div', { class: 'small-2 columns' },
- m('a.panel-graphic-property-remove.button.alert', {
- onclick: vm.removeProperty.bind(vm, prop),
- }, 'Remove')
- )
- ])
- ),
- ]),
- m('.row', [
- m('div', { class: 'small-10 columns panel-graphic-property-item' },
- m('input[type=text]', {
- value: vm.newProperty(),
- oninput: m.withAttr('value', vm.newProperty),
- })
- ),
- m('div', { class: 'small-2 columns' },
- m('a.panel-graphic-property-add.button', {
- onclick: vm.addProperty.bind(vm),
- }, 'Add')
- ),
- ]),
- /* -------- Text Properties -------- */
- m('label', 'Text Fields'),
- m('div', [
- graphic.settings.textfields.map((prop, index) =>
- m('.row', { key: 'add-prop-' + index }, [
- m('div', { class: 'small-10 columns panel-graphic-property-item' },
- m('input[type=text]', {
- readonly: true,
- value: prop,
- })
- ),
- m('div', { class: 'small-2 columns' },
- m('a.panel-graphic-property-remove.button.alert', {
- onclick: vm.removeDataField.bind(vm, 'textfields', prop),
- }, 'Remove')
- )
- ])
- ),
- ]),
- m('.row', [
- m('div', { class: 'small-10 columns panel-graphic-property-item' },
- m('input[type=text]', {
- value: vm.newTextField(),
- oninput: m.withAttr('value', vm.newTextField),
- })
- ),
- m('div', { class: 'small-2 columns' },
- m('a.panel-graphic-property-add.button', {
- onclick: vm.addTextField.bind(vm),
- }, 'Add')
- ),
- ]),
- /* -------- Delete -------- */
- m('a.panel-graphic-delete.button.alert', {
- onclick: vm.remove.bind(vm),
+ components.error(module.mainTemplateError),
+
+ // Remove
+ m('button.red.graphic-delete', {
+ onclick: module.remove.bind(module),
}, 'Delete graphic'),
]
}
diff --git a/app/main/graphic/engine/text.js b/app/main/graphic/engine/text.js
index 29e0e0d..263d7c9 100644
--- a/app/main/graphic/engine/text.js
+++ b/app/main/graphic/engine/text.js
@@ -1,109 +1,121 @@
const m = require('mithril')
-const components = require('../../components')
+const components = require('../../common/components')
-exports.view = function(ctlr, graphic, vm) {
+exports.view = function(module, graphic) {
if (!graphic.settings.properties) {
graphic.settings.properties = []
}
if (graphic.settings.properties.length === 0) {
return [
- m('p', 'No properties have been defined.'),
- m('p', 'Click settings to create and define properties to display.'),
+ m('p.settings-empty', `
+ No properties have been defined.
+ This graphic needs properties to be defined before usage.
+ Click the settings button to define the properties for this graphic.
+ `),
+ m('button.settings-empty-button', {
+ onclick: () => module.switchView(),
+ }, module.changeViewTitle()),
]
}
return [
- components.presetOnlyList(vm),
- graphic.settings.properties.map((prop, index) =>
- m('label', { key: index }, [
- prop,
- m('input[type=text]', {
- value: vm.current[prop] || '',
- oninput: vm.updated.bind(vm, prop, 'current'),
+ m('div.graphic-presetadd', [
+ m('h3.graphic-presetadd-header', 'Create preset/display graphic'),
+ graphic.settings.properties.map((prop, index) => m.fragment({ key: `prop-${index}` }, [
+ m('label', { for: `preset-add-${index}` }, prop),
+ m(`input#preset-add-${index}[type=text]`, {
+ value: module.current[prop] || '',
+ oninput: module.updated.bind(module, prop, 'current'),
}),
- ])
- ),
- components.presetButtons(vm),
+ ])),
+ components.presetButtons(module, 'Display live now', 'Add to preset list'),
+ ]),
+ components.presetOnlyList(module, graphic, 'Presets'),
]
}
-exports.settings = function(cltr, graphic, vm) {
+exports.settings = function(module, graphic) {
return [
- m('label', [
- 'Name',
- m('input[type=text]', {
- value: graphic.name,
- oninput: vm.updated.bind(vm, 'name'),
- }),
+ // Name
+ m('label.graphic-label', { for: 'graphic-name' }, 'Graphic ID'),
+ m('input#graphic-name[type=text]', {
+ value: graphic.name,
+ oninput: module.updated.bind(module, 'name'),
+ }),
+
+ // HTML
+ m('label.graphic-label', { for: 'graphic-html' }, [
+ 'Graphic HTML (',
+ m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
+ ' available: ',
+ graphic.settings.properties.map(prop =>
+ `<%- ${prop} %>`
+ ).join(', '),
+ ')',
]),
- m('label', [
- 'HTML (',
- m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
- ' available: ',
- graphic.settings.properties.map(prop =>
- `<%- ${prop} %>`
- ).join(', '),
- ')',
- m('p', ``),
- m('textarea', {
- rows: '4',
- oninput: vm.updated.bind(null, 'settings.html'),
- value: graphic.settings.html || '',
- }),
- m('p', `
`),
+ m('p.graphic-helper', ``),
+ m('textarea#graphic-html', {
+ rows: '4',
+ oninput: module.updated.bind(null, 'settings.html'),
+ value: graphic.settings.html || '',
+ }),
+ m('p.graphic-helper.bottom', `
`),
+
+ // CSS
+ m('label.graphic-label', { for: 'graphic-css' }, 'Graphic CSS'),
+ m('p.graphic-helper', ''),
+
+ // Main display template
+ m('label.graphic-label', { for: 'graphic-main' }, [
+ 'Graphic control display template (',
+ m('a', { href: 'https://lodash.com/docs#template', target: '_blank' }, 'variables'),
+ ' available: ',
+ graphic.settings.properties.map(prop =>
+ `<%- ${prop} %>`
+ ).join(', '),
+ ')',
]),
- m('label', [
- 'CSS',
- m('textarea', {
- rows: '4',
- oninput: vm.updated.bind(null, 'settings.css'),
- value: graphic.settings.css || '',
- })
- ]),
- m('label', [
- 'Main property',
- m('select', {
- onchange: vm.updated.bind(vm, 'settings.main'),
- }, graphic.settings.properties.map((prop, index) =>
- m('option', {
- key: 'prop-list-' + index,
- value: prop,
- selected: prop === graphic.settings.main,
- }, prop)
- ))
- ]),
- m('label', 'Properties'),
- m('div', [
- graphic.settings.properties.map((prop, index) =>
- m('.row', { key: 'add-prop-' + index }, [
- m('div', { class: 'small-10 columns panel-graphic-property-item' },
- m('input[type=text]', {
- readonly: true,
- value: prop,
- })
- ),
- m('div', { class: 'small-2 columns' },
- m('a.panel-graphic-property-remove.button.alert', {
- onclick: vm.removeProperty.bind(vm, prop),
- }, 'Remove')
- )
- ])
- ),
- ]),
- m('.row', [
- m('div', { class: 'small-10 columns panel-graphic-property-item' },
+ m('input#graphic-main[type=text]', {
+ value: graphic.settings.main,
+ oninput: module.updated.bind(module, 'settings.main'),
+ }),
+
+ // Property list
+ m('label.graphic-label', 'Properties'),
+ graphic.settings.properties.map((prop, index) =>
+ m('div.graphic-property', { key: `prop-${index}` }, [
m('input[type=text]', {
- value: vm.newProperty(),
- oninput: m.withAttr('value', vm.newProperty),
- })
- ),
- m('div', { class: 'small-2 columns' },
- m('a.panel-graphic-property-add.button', {
- onclick: vm.addProperty.bind(vm),
- }, 'Add')
- ),
+ readonly: true,
+ value: prop,
+ }),
+ m('button.red', {
+ onclick: module.removeProperty.bind(module, prop),
+ }, 'Remove'),
+ ]),
+ ),
+ graphic.settings.properties.length === 0 && m('p.graphic-empty', 'No properties exist yet.') || [],
+
+ // Add a new property
+ m('label.graphic-label', { for: 'graphic-newproperty' }, 'Add new graphic property'),
+ m('div.graphic-property', [
+ m('input#graphic-newproperty[type=text]', {
+ value: module.newProperty,
+ oninput: m.withAttr('value', val => (module.newProperty = val)),
+ }),
+ m('button', {
+ onclick: module.addProperty.bind(module),
+ }, 'Add'),
]),
- m('a.panel-graphic-delete.button.alert', {
- onclick: vm.remove.bind(vm),
+ components.error(module.mainTemplateError),
+
+ // Remove
+ m('button.red.graphic-delete', {
+ onclick: module.remove.bind(module),
}, 'Delete graphic'),
]
}
diff --git a/app/main/graphic/module.js b/app/main/graphic/module.js
new file mode 100644
index 0000000..b307a54
--- /dev/null
+++ b/app/main/graphic/module.js
@@ -0,0 +1,260 @@
+const _ = require('lodash')
+const m = require('mithril')
+const createModule = require('../common/module')
+const socket = require('../../socket')
+const view = require('./view')
+const dragula = require('dragula')
+
+const Graphic = createModule({
+ init: function(vnode) {
+ this.fetchData(vnode)
+ },
+
+ onupdate: function(vnode) {
+ this.fetchData(vnode)
+ },
+
+ fetchData: function(vnode) {
+ if (this.currentId === vnode.attrs.id) return
+
+ if (this.currentId && this.currentId !== vnode.attrs.id) {
+ this.unmonitor('graphic.single', this.currentId)
+ this.unmonitor('preset.all', this.currentId)
+ }
+
+ this.currentId = vnode.attrs.id
+
+ this.monitor(
+ 'graphic',
+ 'graphic.single',
+ {},
+ vnode.attrs.id,
+ () => this.recheckTemplate()
+ )
+ this.monitor('presets', 'preset.all', [], vnode.attrs.id)
+
+ this.currentView = 'view'
+ this.current = {}
+ this.displayRemove = false
+ this.newProperty = ''
+ this.newTextField = ''
+ this.mainTemplateString = ''
+ this.mainTemplateError = ''
+ this.mainTemplate = _.template('')
+ },
+
+ recheckTemplate: function() {
+ if (this.graphic.settings.main !== this.mainTemplateString) {
+ this.mainTemplateError = ''
+ this.mainTemplateString = this.graphic.settings.main
+
+ try {
+ this.mainTemplate = _.template(this.mainTemplateString)
+ } catch (e) {
+ this.mainTemplateError = `Invalid template: ${e.message}`
+ }
+ }
+ },
+
+ updated: function(name, variable, cont) {
+ let target = variable
+ let control = cont
+
+ if (!control) {
+ control = variable
+ target = 'graphic'
+ }
+ _.set(this[target], name, control.target.value)
+
+ this.recheckTemplate()
+
+ if (target === 'graphic') {
+ socket.emit('graphic.update', this.graphic)
+ }
+ },
+
+ addDataField: function(type, name) {
+ if (!name) {
+ return 'Please type in proper name'
+ }
+
+ if (this.graphic.settings[type].includes(name)) {
+ return 'A property with that name already exists'
+ }
+
+ this.graphic.settings[type].push(name)
+
+ socket.emit('graphic.update', this.graphic)
+
+ return null
+ },
+
+ addProperty: function() {
+ this.error = this.addDataField('properties', this.newProperty)
+
+ if (!this.error) {
+ this.newProperty = ''
+
+ if (!this.graphic.settings.main) {
+ this.graphic.settings.main = `<%- ${ this.graphic.settings.properties[0] } %>`
+ this.recheckTemplate()
+ socket.emit('graphic.update', this.graphic)
+ }
+ }
+ },
+
+ addTextField: function() {
+ this.error = this.addDataField('textfields', this.newTextField)
+
+ if (!this.error) {
+ this.newTextField = ''
+ }
+ },
+
+ removeDataField: function(type, name) {
+ this.graphic.settings[type].splice(
+ this.graphic.settings[type].indexOf(name), 1)
+
+ if (type === 'properties' && this.graphic.settings.properties.length === 0) {
+ this.graphic.settings.main = ''
+ this.recheckTemplate()
+ }
+
+ socket.emit('graphic.update', this.graphic)
+ },
+
+ removeProperty: function(prop) {
+ this.removeDataField('properties', prop)
+ },
+
+ presetlistInit: function(control) {
+ this.initDragula(control, (source, target) => {
+ let dragOldIndex = _.findIndex(this.presets, { id: Number(source.getAttribute('data')) })
+ let targetOldIndex = this.presets.length - 1
+ if (target) {
+ targetOldIndex = _.findIndex(this.presets, { id: Number(target.getAttribute('data')) })
+ }
+
+ this.presets.splice(targetOldIndex, 0, this.presets.splice(dragOldIndex, 1)[0])
+
+ this.presets.forEach((item, i) => {
+ item.sort = i + 1
+ })
+
+ socket.emit('preset.patch', this.presets)
+ })
+ },
+
+ cleanCurrent: function() {
+ if (this.graphic.engine === 'countdown') {
+ this.current.text = this.graphic.settings.text
+ this.current.countdown = this.graphic.settings.countdown
+ this.current.finished = this.graphic.settings.finished
+
+ if (!this.current.countdown) {
+ this.error = '"Count to" needs to be defined'
+ } else {
+ let test = new Date(this.current.countdown.replace(' ', 'T'))
+ if (!test.getTime()) {
+ this.error = '"Count to" has to be valid date and time'
+ }
+ }
+ } else {
+ this.graphic.settings.properties.forEach(prop => {
+ if (!this.current[prop]) {
+ this.current[prop] = ''
+ }
+ })
+ }
+ },
+
+ addPreset: function() {
+ this.error = ''
+
+ this.cleanCurrent()
+
+ if (this.error) return
+
+ if (this.graphic.engine === 'countdown') {
+ this.current.countdown = null
+ }
+
+ socket.emit('preset.add', {
+ graphic_id: this.graphic.id,
+ values: this.current,
+ })
+ },
+
+ removePreset: function(preset) {
+ socket.emit('preset.remove', preset)
+ },
+
+ remove: function() {
+ socket.emit('graphic.remove', this.graphic)
+ m.route.set('/')
+ },
+
+ displayPreset: function(preset) {
+ if (this.graphic.engine === 'countdown') {
+ this.graphic.settings.text = preset.values.text
+ this.graphic.settings.finished = preset.values.finished
+ socket.emit('graphic.update', this.graphic)
+ return
+ }
+
+ socket.emit('content.display', {
+ graphic: this.graphic,
+ data: preset.values,
+ })
+ },
+
+ schedulePreset: function(preset) {
+ socket.emit('schedule.add', {
+ graphic_id: this.graphic.id,
+ values: preset.values,
+ })
+ },
+
+ scheduleCurrent: function() {
+ this.error = ''
+
+ this.cleanCurrent()
+
+ if (this.error) return
+
+ socket.emit('schedule.add', {
+ graphic_id: this.graphic.id,
+ values: this.current,
+ })
+ },
+
+ displayCurrent: function() {
+ this.error = ''
+
+ this.cleanCurrent()
+
+ if (this.error) return
+
+ socket.emit('content.display', {
+ graphic: this.graphic,
+ data: this.current,
+ })
+ },
+
+ switchView: function() {
+ if (this.currentView === 'view') {
+ this.currentView = 'settings'
+ } else {
+ this.currentView = 'view'
+ }
+ },
+
+ changeViewTitle: function() {
+ if (this.currentView === 'view') {
+ return 'Settings'
+ }
+ return 'Control'
+ },
+}, view)
+
+module.exports = Graphic
diff --git a/app/main/graphic/view.js b/app/main/graphic/view.js
index dae9e69..56b2a55 100644
--- a/app/main/graphic/view.js
+++ b/app/main/graphic/view.js
@@ -1,6 +1,5 @@
const m = require('mithril')
-const Graphic = require('./controller')
-const components = require('../components')
+const components = require('../common/components')
const engines = {
text: require('./engine/text'),
@@ -8,21 +7,20 @@ const engines = {
schedule: require('./engine/schedule'),
}
-Graphic.view = function(ctrl) {
- graphic = Graphic.vm.graphic
+module.exports = function() {
+ let graphic = this.graphic
+ let currentView = graphic.engine && engines[graphic.engine][this.currentView] || null
- return m('div', [
- m('h3.container-header', 'Graphic'),
- m('div.container-panel.panel-graphic',
- !graphic.name && m('p', 'Loading...') ||
- [
- m('a.panel-graphic-settings.button', {
- onclick: Graphic.vm.switchView
- }, Graphic.vm.currentView === 'view' && 'Settings' || 'Control'),
- m('h4', graphic.name),
- components.error(Graphic.vm.error),
- engines[graphic.engine][Graphic.vm.currentView](ctrl, graphic, Graphic.vm),
- ]
- ),
- ])
+ return [
+ m('h4.header', 'Graphic'),
+ m('header', [
+ m('h3', graphic.name),
+ m('button', {
+ onclick: () => this.switchView(),
+ }, this.changeViewTitle()),
+ ]),
+ components.error(this.error),
+ !currentView && m('p', 'Loading...')
+ || currentView(this, graphic),
+ ]
}
diff --git a/app/main/header.js b/app/main/header.js
index 555d21b..2b04646 100644
--- a/app/main/header.js
+++ b/app/main/header.js
@@ -1,10 +1,22 @@
const m = require('mithril')
-const createModule = require('./module')
+const createModule = require('./common/module')
const socket = require('../socket')
const Header = createModule({
init: function() {
- this.monitor('list', 'content.list', [])
+ this.currentLength = 0
+ this.updateMargin = false
+ this.connected = socket.connected
+ this.monitor('list', 'content.list', [], null, () => this.checkChanged())
+
+ socket.on('connect', () => {
+ this.connected = true
+ m.redraw()
+ })
+ socket.on('disconnect', () => {
+ this.connected = false
+ m.redraw()
+ })
},
hide: function(item) {
@@ -12,19 +24,38 @@ const Header = createModule({
name: item.name,
})
},
-}, function(ctrl) {
- return m('div.header', Header.vm.list.length > 0 && [
- m('h3.container-header', 'Currently active'),
- m('ul.header-list', [
- Header.vm.list.map((item, index) =>
- m('li.header-item', { key: 'header-' + index, }, [
- m('a.header-item-hide.button.alert', {
- onclick: Header.vm.hide.bind(Header.vm, item),
+
+ onupdate: function() {
+ if (!this.updateMargin) return
+ this.updateMargin = false
+
+ let header = document.getElementById('header')
+ let container = document.getElementById('container')
+
+ container.style.marginTop = `${ header.clientHeight - 1}px`
+ },
+
+ checkChanged: function() {
+ if (this.currentLength === this.list.length) return
+ this.currentLength = this.list.length
+ this.updateMargin = true
+ },
+}, function() {
+ return [
+ this.list.length > 0 && [
+ m('h4', 'Active graphics'),
+ this.list.map(item =>
+ m('div.item', { key: `header-${item.id}` }, [
+ m('h3', `${item.name} - ${item.display}`),
+ m('button.red', {
+ onclick: () => this.hide(item),
}, 'Hide'),
- m('div.header-item-display', `${item.name} - ${item.display}`),
])
),
- ]),
- ] || '')
+ ] || null,
+ !this.connected && m('div.disconnected', `
+ Lost connection with server, Attempting to reconnect
+ `) || null,
+ ]
})
module.exports = Header
diff --git a/app/main/index.js b/app/main/index.js
index b7a94f7..6fb520b 100644
--- a/app/main/index.js
+++ b/app/main/index.js
@@ -19,14 +19,15 @@ const m = require('mithril')
const Header = require('./header')
const Menu = require('./menu')
-const Add = require('./add')
-const Graphic = require('./graphic')
+const Add = require('./add/module')
+const Graphic = require('./graphic/module')
+const Dagskra = require('./dagskra/module')
m.mount(document.getElementById('header'), Header)
m.mount(document.getElementById('menu'), Menu)
m.route(document.getElementById('content'), '/', {
- '/': {},
+ '/': Dagskra,
'/add': Add,
'/graphic/:id': Graphic,
-});
+})
diff --git a/app/main/menu.js b/app/main/menu.js
index 333b05b..7128c45 100644
--- a/app/main/menu.js
+++ b/app/main/menu.js
@@ -1,24 +1,70 @@
const m = require('mithril')
-const createModule = require('./module')
+const createModule = require('./common/module')
+const socket = require('../socket')
const Menu = createModule({
init: function() {
this.monitor('list', 'graphic.all', [])
- }
-}, function(ctrl) {
- return m('div', [
- m('h3.container-header', 'Graphics'),
- m('div.container-panel.menu', [
- m('ul.menu-list', [
- // m('a', { href: `/`, config: m.route }, 'Home'),
- Menu.vm.list.map((item) =>
- m('li.menu-item', [
- m('a', { href: `/graphic/${item.id}`, config: m.route }, item.name),
- ])
- )
- ]),
- m('a.menu-item-add', { href: '/add', config: m.route }, 'Add graphic' ),
- ]),
- ])
+ this.monitor('settings', 'settings.all', {})
+ this.monitor('schedule', 'schedule.total', { total: 0 })
+ this.monitor('status', 'casparcg.status', {
+ connected: false,
+ playing: false,
+ })
+ this.newHost = ''
+ this.enableEdit = false
+ },
+
+ setHost(value) {
+ this.newHost = value
+ this.enableEdit = true
+ },
+
+ saveNewHost() {
+ socket.emit('settings.update', {
+ name: 'casparcg',
+ value: this.newHost,
+ })
+
+ this.newHost = ''
+ this.enableEdit = false
+ },
+}, function() {
+ return [
+ m('a', {
+ href: '/',
+ oncreate: m.route.link,
+ class: m.route.get() === '/' && 'active' || '',
+ }, `Schedule (${this.schedule.total})` ),
+ m('h4.header.header--space', 'Graphics'),
+ this.list.map((item) =>
+ m('a', {
+ href: `/graphic/${item.id}`,
+ oncreate: m.route.link,
+ class: m.route.get() === `/graphic/${item.id}` && 'active' || '',
+ }, item.name)
+ ),
+ m('h5.header.header--space', 'Other'),
+ m('a', {
+ href: '/add',
+ oncreate: m.route.link,
+ class: m.route.get() === '/add' && 'active' || '',
+ }, 'Add graphic' ),
+ m('h5.header.header--space', 'CasparCG Status'),
+ m('input[type=text]', {
+ placeholder: 'Host IP',
+ value: this.newHost || this.settings.casparcg || '',
+ oninput: control => this.setHost(control.target.value),
+ }),
+ this.enableEdit && m('button', {
+ onclick: () => this.saveNewHost(),
+ }, 'Connect'),
+ m('div.status', {
+ class: this.status.connected && 'green',
+ }, 'connected'),
+ m('div.status', {
+ class: this.status.playing && 'green',
+ }, 'playing'),
+ ]
})
module.exports = Menu
diff --git a/app/main/store.js b/app/main/store.js
index dcb862c..b580c59 100644
--- a/app/main/store.js
+++ b/app/main/store.js
@@ -5,6 +5,7 @@ const events = {}
// Listen on all events
let onevent = socket.onevent
+let disconnected = false
socket.onevent = function(packet) {
let args = packet.data || []
@@ -13,6 +14,25 @@ socket.onevent = function(packet) {
onevent.call(this, packet)
}
+socket.on('disconnect', () => {
+ disconnected = true
+})
+
+socket.on('connect', () => {
+ if (disconnected) {
+ Object.keys(events).forEach(event => {
+ let name = event
+ let id = null
+ if (event.indexOf(':') > 0) {
+ name = event.split(':')[0]
+ id = Number(event.split(':')[1])
+ }
+ socket.emit(name, { id: id })
+ })
+ }
+ disconnected = false
+})
+
function genId(name, id) {
if (id) {
return `${name}:${id}`
@@ -21,6 +41,10 @@ function genId(name, id) {
}
const store = {
+ getId: function(name, id) {
+ return genId(name, id)
+ },
+
get: function(name, id) {
return storage[genId(name, id)]
},
@@ -39,18 +63,24 @@ const store = {
}
socket.on('*', (event, data) => {
- let name = genId(event, data && data.id)
+ let id = data && data.id
+
+ let name = genId(event, id)
+
if (events[name]) {
storage[name] = data
events[name]()
}
if (event.indexOf('single') >= 0) {
let check = event.replace('single', 'all')
- if (events[name]) {
+ if (events[check]) {
let index = _.findIndex(storage[check], { id: data.id })
if (index > -1) {
storage[check][index] = data
- events[name]()
+ events[check]()
+ } else {
+ storage[check].push(data)
+ events[check]()
}
}
}
diff --git a/app/status/index.js b/app/status/index.js
new file mode 100644
index 0000000..4cf6d19
--- /dev/null
+++ b/app/status/index.js
@@ -0,0 +1,106 @@
+const socket = require('../socket')
+const m = require('mithril')
+
+const Status = {
+ oninit: function() {
+ this.current = []
+ this.connected = socket.connected
+ this.status = {
+ connected: false,
+ playing: false,
+ }
+
+ socket.on('casparcg.status', data => {
+ this.status = data
+ m.redraw()
+ })
+ socket.on('content.list', data => {
+ this.current = data
+ m.redraw()
+ })
+ socket.on('connect', () => this.updateConnected(true))
+ socket.on('disconnect', () => this.updateConnected(false))
+ },
+
+ updateConnected: function(connected) {
+ this.connected = connected
+ m.redraw()
+ },
+
+ hide: function(item) {
+ socket.emit('content.hide', {
+ name: item.name,
+ })
+ },
+
+ view: function() {
+ return [
+ m('header', [
+ m('h2', 'Active graphics:'),
+ m('div.status', {
+ class: this.status.connected && 'green',
+ }, 'connected'),
+ m('div.status', {
+ class: this.status.playing && 'green',
+ }, 'playing'),
+ ]),
+ this.current.map(item =>
+ m('div.item', { key: `header-${item.id}` }, [
+ m('h3', `${item.name} - ${item.display}`),
+ m('button', {
+ onclick: () => this.hide(item),
+ }, 'Hide'),
+ ])
+ ),
+ this.current.length === 0 && m('div.empty', 'No active graphics') || null,
+ !this.connected && m('div.disconnected', `
+ Lost connection with server, Attempting to reconnect
+ `) || null,
+ ]
+ },
+}
+
+m.mount(document.getElementById('container'), Status)
+
+// var engines = {
+// text: require('./text'),
+// countdown: require('./countdown'),
+// schedule: require('./schedule'),
+// }
+
+// function display(data) {
+// var exists = document.getElementById(data.graphic.name)
+
+// var engine = data.graphic.engine
+
+// if (exists) {
+// exists.innerHtml = data.html
+// exists.tag.innerHtml = data.css
+
+// engines[engine].update(data)
+// return
+// }
+
+// if (engines[engine]) {
+// engines[engine].init(data)
+// }
+// }
+
+// socket.on('client.display', display)
+
+// socket.on('client.hide', function (data) {
+// var exists = document.getElementById(data.name)
+
+// if (exists) {
+// exists.classList.remove('root-element-display')
+
+// window.setTimeout(function () {
+// exists.tag.remove()
+// exists.remove()
+// }, 1500)
+// }
+// })
+
+// socket.on('client.reset', function(data) {
+// data.forEach(display)
+// })
diff --git a/app/styl/main.styl b/app/styl/main.styl
index 8c0ed40..a07d072 100644
--- a/app/styl/main.styl
+++ b/app/styl/main.styl
@@ -1,156 +1,306 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+ html, body, div, span, applet, object, iframe,
+ h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+ a, abbr, acronym, address, big, cite, code,
+ del, dfn, em, img, ins, kbd, q, s, samp,
+ small, strike, strong, sub, sup, tt, var,
+ b, u, i, center,
+ dl, dt, dd, ol, ul, li,
+ fieldset, form, label, legend,
+ table, caption, tbody, tfoot, thead, tr, th, td,
+ article, aside, canvas, details, embed,
+ figure, figcaption, footer, header, hgroup,
+ menu, nav, output, ruby, section, summary,
+ time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+ }
+ /* HTML5 display-role reset for older browsers */
+ article, aside, details, figcaption, figure,
+ footer, header, hgroup, menu, nav, section {
+ display: block;
+ }
+ body {
+ line-height: 1;
+ }
+ ol, ul {
+ list-style: none;
+ }
+ blockquote, q {
+ quotes: none;
+ }
+ blockquote:before, blockquote:after,
+ q:before, q:after {
+ content: '';
+ content: none;
+ }
+ table {
+ border-collapse: collapse;
+ border-spacing: 0;
+ }
+
+ input {
+ font-size: 16px;
+ -webkit-appearance: none;
+ border-radius: 0;
+ }
+
+ button {
+ outline: none;
+ border: none;
+ cursor: pointer;
+ }
+
+ a {
+ text-decoration: none;
+ }
+
body {
background: #3f3f41;
color: #f1f1f1;
+ display: flex;
+ min-height: 100vh;
+ flex-direction: column;
+ font-family: Helvetica, sans-serif, Arial;
}
h4 {
margin-bottom: 2rem;
}
+
+/* Components */
+
+ button {
+ border: none;
+ color: #f1f1f1;
+ background: #2199e8;
+ font-size: 0.6em;
+ height: 3em;
+
+ &.green {
+ background: #3adb78;
+ }
+
+ &.red {
+ background: #ec5840;
+ }
+ }
+
+ .error-box {
+ margin: 1rem 0rem 2rem 0;
+ padding: 1rem;
+ background: #FF0000;
+ color: white;
+ font-size: 0.7em;
+ line-height: 1em;
+ }
+
+$header-size = 0.8em;
+$header-color = #777777;
+
/* Container */
.container {
- padding: 1rem;
- }
-
- .container-header {
- font-size: 1.5rem;
- margin-left: 1rem;
- color: #777777;
- }
-
- .container-panel {
- border: 1px solid #3f3f3f;
- background: #2d2d30;
- padding: 1rem;
- border-radius: 5px;
+ display: flex;
+ align-items: stretch;
+ flex-grow: 2;
}
/* Header */
-
- .header-list {
- list-style-type: none;
- margin: 0;
+ section.current {
+ padding: 0 13px;
+ background: black;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ z-index: 10;
+
+ h4 {
+ color: $header-color;
+ font-size: 0.7em;
+ padding: 0.2em;
+ margin: 0;
+ }
+
+ h3 {
+ font-size: 1em;
+ line-height: 2em;
+ color: #eb6e00;
+ flex-grow: 2;
+ height: 2em;
+ padding-right: 0.5em;
+ overflow: hidden;
+ word-break: break-all;
+ }
+
+ button {
+ width: 80px;
+ flex-shrink: 0;
+ }
+
+ .item {
+ display: flex;
+ margin-bottom: 5px;
+ }
}
-
- .header-item {
- margin-bottom: 1rem;
- }
-
- .header-item-hide {
- float: right;
- width: 5rem;
- border-radius: 6px;
- margin: 0;
- }
-
- .header-item-display {
- background: #070707;
- color: #eb6e00;
- border-radius: 6px;
- padding: 0.5rem 1rem;
- margin-right: 5.5rem;
+
+ .disconnected {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.8);
+ color: white;
+ font-size: 1em;
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
/* Menu */
- .menu-list {
- list-style-type: none;
- margin: 0;
- }
-
- .menu a {
- color: #007acc;
- display: block;
- border: 1px solid #2d2d30;
- padding: 0.2rem 0.5rem;
- }
-
- .menu a:hover {
- color: #f1f1f1;
- border: 1px solid #007acc;
- }
-
- .menu-item-add {
- margin-top: 3rem;
- }
-
-/* Add */
-
- .panel-add {
- padding: 2rem;
- }
-
- .panel-graphic-property-add,
- .panel-graphic-property-remove {
- width: 100%;
- }
-
-/* Graphic */
-
- .panel-graphic-delete {
+ nav {
+ width: 200px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ padding: 10px;
+ background: #2d2d30;
+ text-align: center;
+ .header {
+ color: $header-color;
+ font-size: $header-size;
+ margin-bottom: 10px;
+
+ &--space {
+ margin-top: 2em;
+ }
+ }
+
+ a {
+ font-size: $header-size;
+ line-height: 2.6em;
+ display: block;
+ border: 4px solid #2d2d30;
+ background: #007acc;
+ color: white;
+
+ &.active {
+ background: transparent;
+ border: 4px solid #007acc;
+ }
+
+ &:hover {
+ border: 4px solid #007acc;
+ }
+ }
+
+ input[type=text] {
+ text-align: center;
+ }
+
+ .status {
+ padding: 5px 20px;
+ font-size: 0.8em;
+ color: $header-color;
+ text-align: left;
+ position: relative;
+ margin-left: 1.8em;
+
+ &::after {
+ position: absolute;
+ left: 0;
+ top: calc(50% - 5px);
+ content: '';
+ border: 6px solid #ec5840;
+ }
+
+ &.green::after {
+ border-color: #008000;
+ }
+ }
}
+
+/* Main */
- .panel-graphic-settings {
- float: right;
- margin-right: 1rem;
- }
-
- .panel-graphic-property-item {
- padding-left: 0;
- }
-
- .panel-graphic-preset-add {
- margin-right: 1rem;
- }
-
- .panel-graphic-preset {
- margin-top: 1rem;
- list-style-type: none;
- }
-
- .panel-graphic-preset a {
- width: 100%;
- }
-
-/* Components */
-
- .error-box {
- margin: 0rem 0rem 2rem 0;
- color: #FF0000;
+ main {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ padding: 10px 1em;
+ flex-grow: 2;
+ width: 300px;
+
+ .header {
+ color: $header-color;
+ font-size: $header-size;
+ margin-bottom: 10px;
+ }
}
/* Inputs */
- .panel-graphic-property-item input {
- display: inline-block;
- }
-
label {
+ margin-top: 0.6em;
color: #f1f1f1;
+ font-size: 0.7em;
+
+ & a,
+ & a:hover,
+ & a:visited {
+ color: #aaa;
+ text-decoration: underline;
+ }
}
- input[type="text"],
- textarea {
+ input[type='text'],
+ textarea,
+ select {
+ font-size: 0.6em;
+ padding: 0.5em;
+ margin: 0.5em 0;
background: #333337;
- border-color: #3f3f3f;
+ border: 1px solid #2d2d30;
color: #999999;
transition-property: none !important;
+ outline: none;
+
+ &:hover {
+ color: #f1f1f1;
+ border-color: #007acc;
+ }
+
+ &:focus {
+ background: #333337;
+ color: #f1f1f1;
+ border-color: #007acc;
+ box-shadow: none;
+ }
}
-
- input[type="text"]:hover,
- textarea:hover {
+
+ input[type=submit] {
+ margin-top: 0.6em;
+ border: none;
color: #f1f1f1;
- border-color: #007acc;
- }
-
- input[type="text"]:focus,
- textarea:focus {
- background: #333337;
- color: #f1f1f1;
- border-color: #007acc;
- box-shadow: none;
+ background: #2199e8;
+ font-size: 0.6em;
+ line-height: 3em;
}
input[readonly],
@@ -160,14 +310,14 @@ h4 {
}
select {
- background: #333337;
- border-color: #3f3f3f;
- color: #999999;
+ height: 2.5em;
+ -webkit-appearance: none;
+ border-radius: 0;
background-position: right center;
background-size: 9px 6px;
background-origin: content-box;
background-repeat: no-repeat;
- background-image: url('data:image/svg+xml;utf8,')
+ background-image: url("data:image/svg+xml;utf8,")
}
select:hover {
@@ -187,55 +337,210 @@ h4 {
width: 7rem;
}
+/* Graphic */
+
+ header {
+ display: flex;
+
+ h3 {
+ font-size: 1em;
+ flex-grow: 2;
+ border-bottom: 1px solid #2d2d30;
+ padding-top: 10px;
+ margin-right: 30px;
+ }
+
+ button {
+ border: 0;
+ width: 100px;
+ }
+ }
+
+ .graphic {
+ &-presetlist {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ &-presetadd {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ border: 1px solid #2d2d30;
+ margin: 30px 0 10px;
+ padding: 20px;
+ position: relative;
+
+ &-header {
+ background: #3f3f41;
+ position: absolute;
+ top: -1.3em;
+ left: 10px;
+ font-size: 0.8em;
+ padding: 0.8em 10px;
+ }
+
+ &-buttons {
+ display: flex;
+ margin-top: 10px;
+
+ & button {
+ margin-right: 10px;
+ width: 150px;
+ }
+ }
+ }
+
+ &-presetremove {
+ align-self: center;
+ margin-top: 50px;
+ width: 150px;
+ }
+
+ &-empty {
+ font-size: 0.7em;
+ color: #999;
+ text-align: center;
+ margin: 20px 0;
+ }
+
+ &-delete {
+ align-self: center;
+ margin-top: 30px;
+ width: 150px;
+ }
+
+ &-label {
+ margin-top: 30px;
+ padding-bottom: 0.5em;
+ }
+
+ &-helper {
+ font-size: 0.7em;
+ color: #999;
+ margin: 5px 0 0;
+
+ &.bottom {
+ margin: 0;
+ }
+ }
+
+ &-property,
+ &-preset {
+ display: flex;
+
+ &-reorder {
+ width: 30px;
+ background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDIxIDc5LjE1NTc3MiwgMjAxNC8wMS8xMy0xOTo0NDowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTQgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODQ5NDcyMzY3MTU5MTFFOEEwQjVFQUFCMEYzRDE2QjciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODQ5NDcyMzc3MTU5MTFFOEEwQjVFQUFCMEYzRDE2QjciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo4NDk0NzIzNDcxNTkxMUU4QTBCNUVBQUIwRjNEMTZCNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo4NDk0NzIzNTcxNTkxMUU4QTBCNUVBQUIwRjNEMTZCNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pla8e2wAAABFUExURXd3d////3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d0brzhMAAAAWdFJOUwAAN0NEVXJzdHV2d3h/iImKmaq73e5w9SzPAAAAlElEQVRIx+XT2wqDMBRE0Witt2piW53//9SCoETMwJkXkWY/CgtNBp0jDa/086JgwgNeIwEghpFVAMFOJoCaNNkFMNlIJBImRcrPodJ8ybQ/I285B7mLSCWX+/qL3EVTznK5T/mUu++P7OVyX/9U9T30sLyliUeobR8WmcZ6lnYTrf34HRX8xlbTaZfcA726yziyXX5Joj0NgTfsUQAAAABJRU5ErkJggg==') no-repeat transparent;
+ background-size: 25px;
+ background-position: left center;
+ touch-action: none;
+ }
+
+ & input {
+ flex-grow: 2;
+ margin: 0;
+ }
+
+ & button {
+ width: 100px;
+ border: 1px solid #3f3f41;
+ border-left: none;
+ }
+ }
+ }
+
+ .schedule {
+ &-empty {
+ margin-top: 2em;
+ font-size: 1em;
+ text-align: center;
+ }
+ }
+
+ .settings {
+ &-empty {
+ text-align: center;
+ margin: 50px 0 30px;
+ font-size: 0.8em;
+ color: #999;
+
+ &-button {
+ align-self: center;
+ width: 200px;
+ }
+ }
+ }
+
+/* Dragula */
+@css {
+ #dragcontainer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ }
+
+ .gu-mirror {
+ position: absolute !important;
+ margin: 0 !important;
+ z-index: 9999 !important;
+ opacity: 0.8;
+ -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=80)";
+ filter: alpha(opacity=80);
+ }
+ .gu-hide {
+ display: none !important;
+ }
+ .gu-unselectable {
+ -webkit-user-select: none !important;
+ -moz-user-select: none !important;
+ -ms-user-select: none !important;
+ user-select: none !important;
+ }
+ .gu-transit {
+ opacity: 0.2;
+ -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)";
+ filter: alpha(opacity=20);
+ }
+}
+
/* Media queries */
body {
font-size: 1.5rem;
}
- @media only screen and (max-device-width: 1280px) {
- .header-item-hide {
- width: 9rem;
- line-height: 0rem;
+ @media only screen and (max-device-width: 600px) {
+ #container {
+ flex-direction: column;
}
-
- a.button {
- font-size: 2rem;
- line-height: 0;
+
+ nav {
+ width: auto;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ .header {
+ width: 100%;
+ }
+
+ a {
+ width: calc(50% - 8px);
+ }
+
+ input[type=text] {
+ width: 100%;
+ }
+
+ .status {
+ align-self: center;
+ width: 120px;
+ }
+ }
+
+ #content {
width: auto;
}
-
- .panel-graphic-preset {
- margin: 0;
- }
-
- .panel-graphic-display,
- .panel-graphic-preset-add {
- margin-bottom: 3rem !important;
- }
-
- .panel-graphic-preset-remove {
- padding-right: 0.5rem;
- padding-left: 0.5rem;
- }
-
- .panel-graphic-preset-remove.alert {
- padding-right: 1rem;
- padding-left: 1rem;
- }
-
- .panel-graphic-settings {
- font-size: 1.3rem !important;
- }
-
- .header-item-display {
- font-size: 2rem;
- margin-right: 12.5rem;
- padding: 0.2rem 1rem;
- }
-
- .panel-graphic-property-item input {
- font-size: 2rem;
- height: 3.5rem;
- }
}
diff --git a/app/styl/status.styl b/app/styl/status.styl
new file mode 100644
index 0000000..20de087
--- /dev/null
+++ b/app/styl/status.styl
@@ -0,0 +1,168 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+ html, body, div, span, applet, object, iframe,
+ h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+ a, abbr, acronym, address, big, cite, code,
+ del, dfn, em, img, ins, kbd, q, s, samp,
+ small, strike, strong, sub, sup, tt, var,
+ b, u, i, center,
+ dl, dt, dd, ol, ul, li,
+ fieldset, form, label, legend,
+ table, caption, tbody, tfoot, thead, tr, th, td,
+ article, aside, canvas, details, embed,
+ figure, figcaption, footer, header, hgroup,
+ menu, nav, output, ruby, section, summary,
+ time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+ }
+ /* HTML5 display-role reset for older browsers */
+ article, aside, details, figcaption, figure,
+ footer, header, hgroup, menu, nav, section {
+ display: block;
+ }
+ body {
+ line-height: 1;
+ }
+ ol, ul {
+ list-style: none;
+ }
+ blockquote, q {
+ quotes: none;
+ }
+ blockquote:before, blockquote:after,
+ q:before, q:after {
+ content: '';
+ content: none;
+ }
+ table {
+ border-collapse: collapse;
+ border-spacing: 0;
+ }
+
+ input {
+ font-size: 16px;
+ -webkit-appearance: none;
+ border-radius: 0;
+ }
+
+ button {
+ outline: none;
+ border: none;
+ cursor: pointer;
+ }
+
+ a {
+ text-decoration: none;
+ }
+
+body {
+ background: #3f3f41;
+ color: #eb6e00;
+ display: flex;
+ min-height: 100vh;
+ flex-direction: column;
+ font-family: Helvetica, sans-serif, Arial;
+}
+
+$header-size = 2.3em;
+
+#container {
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+ flex-grow: 2;
+
+ h3 {
+ background: black;
+ font-size: $header-size;
+ line-height: ($header-size);
+ color: #eb6e00;
+ height: ($header-size);
+ overflow: hidden;
+ padding: 0 0.3em;
+ flex-grow: 2;
+ border-radius: 5px 0 0 5px;
+ word-break: break-all;
+ }
+
+ button {
+ border: none;
+ color: black;
+ background: #eb6e00;
+ font-size: 2em;
+ width: 80px;
+ flex-shrink: 0;
+ border-radius: 0 5px 5px 0;
+ }
+
+ .item {
+ display: flex;
+ margin: 5px;
+ }
+
+ .empty {
+ flex-grow: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #ccc;
+ font-size: 2em;
+ }
+}
+
+header {
+ display: flex;
+ margin-bottom: 5px;
+ color: #ccc;
+
+ h2 {
+ font-size: 3em;
+ line-height: 64px;
+ padding: 0 0.3em;
+ flex-grow: 2;
+ }
+
+ .status {
+ padding: 0 20px 0 30px;
+ line-height: 64px;
+ font-size: 2em;
+ color: $header-color;
+ text-align: left;
+ position: relative;
+
+ &::after {
+ position: absolute;
+ left: 0;
+ top: calc(50% - 7px);
+ content: '';
+ border: 10px solid #ec5840;
+ }
+
+ &.green::after {
+ border-color: #00FF00;
+ }
+ }
+}
+
+.disconnected {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.8);
+ color: white;
+ font-size: 3em;
+ display: flex;
+ text-align: center;
+ justify-content: center;
+ align-items: center;
+}
diff --git a/install_run.bat b/install_run.bat
new file mode 100644
index 0000000..9bcf17e
--- /dev/null
+++ b/install_run.bat
@@ -0,0 +1,8 @@
+git pull
+npm install
+npm run build
+npm start | bunyan
+echo.
+echo EXITED
+echo.
+PAUSE
diff --git a/migrations/20180625133103_settings.js b/migrations/20180625133103_settings.js
new file mode 100644
index 0000000..59446d6
--- /dev/null
+++ b/migrations/20180625133103_settings.js
@@ -0,0 +1,24 @@
+/* eslint-disable */
+'use strict';
+
+exports.up = function(knex, Promise) {
+ return Promise.all([
+ knex.schema.createTable('settings', function(table) {
+ table.increments()
+ table.text('name')
+ table.text('value')
+ table.boolean('is_deleted')
+ }).then(() => {
+ return knex('settings').insert({
+ name: 'casparcg',
+ value: ''
+ })
+ }),
+ ]);
+};
+
+exports.down = function(knex, Promise) {
+ return Promise.all([
+ knex.schema.dropTable('settings'),
+ ]);
+};
diff --git a/migrations/20180626143319_content.js b/migrations/20180626143319_content.js
new file mode 100644
index 0000000..4350238
--- /dev/null
+++ b/migrations/20180626143319_content.js
@@ -0,0 +1,22 @@
+/* eslint-disable */
+'use strict';
+
+exports.up = function(knex, Promise) {
+ return Promise.all([
+ knex.schema.createTable('content', function(table) {
+ table.increments()
+ table.text('graphic')
+ table.text('name')
+ table.text('html')
+ table.text('css')
+ table.text('data')
+ table.boolean('is_deleted')
+ }),
+ ]);
+};
+
+exports.down = function(knex, Promise) {
+ return Promise.all([
+ knex.schema.dropTable('content'),
+ ]);
+};
diff --git a/migrations/20180626154925_schedule.js b/migrations/20180626154925_schedule.js
new file mode 100644
index 0000000..2ce674f
--- /dev/null
+++ b/migrations/20180626154925_schedule.js
@@ -0,0 +1,20 @@
+/* eslint-disable */
+'use strict';
+
+exports.up = function(knex, Promise) {
+ return Promise.all([
+ knex.schema.createTable('schedule', function(table) {
+ table.increments()
+ table.integer('graphic_id').references('graphics.id')
+ table.text('values')
+ table.integer('sort')
+ table.boolean('is_deleted')
+ }),
+ ]);
+};
+
+exports.down = function(knex, Promise) {
+ return Promise.all([
+ knex.schema.dropTable('schedule'),
+ ]);
+};
diff --git a/package.json b/package.json
index 2a97b12..2f9c267 100644
--- a/package.json
+++ b/package.json
@@ -8,14 +8,18 @@
"watch:styl": "stylus -w -m app/styl/main.styl --out public",
"build-client:styl": "stylus -m app/styl/client.styl --out public",
"watch-client:styl": "stylus -w -m app/styl/client.styl --out public",
- "watch:js": "watchify -t babelify app/main.js -o public/main.js --debug",
- "build:js": "browserify app/main.js -o public/main.js --debug -t [ babelify ]",
- "watch-client:js": "watchify -t babelify app/client.js -o public/client.js --debug",
- "build-client:js": "browserify app/client.js -o public/client.js --debug -t [ babelify ]",
+ "build-status:styl": "stylus -m app/styl/status.styl --out public",
+ "watch-status:styl": "stylus -w -m app/styl/status.styl --out public",
+ "watch:js": "watchify -t babelify app/main/index.js -o public/main.js --debug",
+ "build:js": "browserify app/main/index.js -o public/main.js --debug -t [ babelify ]",
+ "watch-client:js": "watchify -t babelify app/client/index.js -o public/client.js --debug",
+ "build-client:js": "browserify app/client/index.js -o public/client.js --debug -t [ babelify ]",
+ "watch-status:js": "watchify -t babelify app/status/index.js -o public/status.js --debug",
+ "build-status:js": "browserify app/status/index.js -o public/status.js --debug -t [ babelify ]",
"watch:server": "nodemon index.js",
"start": "node index.js",
- "dev-run": "run-p watch:js watch-client:js watch:server watch:styl watch-client:styl",
- "prod-run": "npm run build:js && npm run build-client:js && npm run build:styl && npm run build-client:styl && npm start",
+ "dev-run": "run-p watch:js watch-client:js watch-status:js watch:server watch:styl watch-client:styl watch-status:styl",
+ "prod-run": "npm run build:js && npm run build-client:js && npm run build-status:js && npm run build:styl && npm run build-client:styl && npm run build-status:styl && npm start",
"test": "env NODE_ENV=test mocha --require babel-register --recursive --reporter dot",
"docker": "docker run -it --rm --name my-running-script -v \"$PWD\":/usr/src/app -w /usr/src/app node:alpine",
"docker:test": "npm run docker -- npm install && npm run test",
@@ -43,6 +47,9 @@
"babel-register": "^6.26.0",
"bookshelf": "^0.11.1",
"bunyan": "^1.8.12",
+ "casparcg-connection": "^4.1.0",
+ "dragula": "^3.7.2",
+ "ip": "^1.1.5",
"knex": "^0.14.2",
"koa": "^2.4.1",
"koa-better-serve": "^2.0.7",
@@ -54,6 +61,8 @@
"sqlite3": "^3.1.13"
},
"devDependencies": {
+ "eslint": "^4.16.0",
+ "eslint-plugin-mocha": "^4.11.0",
"babelify": "^8.0.0",
"mocha": "^4.0.1",
"nodemon": "^1.12.1",
diff --git a/public/foundation.css b/public/foundation.css
deleted file mode 100644
index 09f17ef..0000000
--- a/public/foundation.css
+++ /dev/null
@@ -1,2555 +0,0 @@
-/**
- * Foundation for Sites by ZURB
- * Version 6.2.1
- * foundation.zurb.com
- * Licensed under MIT Open Source
- */
-/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
-/**
- * 1. Set default font family to sans-serif.
- * 2. Prevent iOS and IE text size adjust after device orientation change,
- * without disabling user zoom.
- */
-html {
- font-family: sans-serif;
- /* 1 */
- -ms-text-size-adjust: 100%;
- /* 2 */
- -webkit-text-size-adjust: 100%;
- /* 2 */ }
-
-/**
- * Remove default margin.
- */
-body {
- margin: 0; }
-
-/* HTML5 display definitions
- ========================================================================== */
-/**
- * Correct `block` display not defined for any HTML5 element in IE 8/9.
- * Correct `block` display not defined for `details` or `summary` in IE 10/11
- * and Firefox.
- * Correct `block` display not defined for `main` in IE 11.
- */
-article,
-aside,
-details,
-figcaption,
-figure,
-footer,
-header,
-hgroup,
-main,
-menu,
-nav,
-section,
-summary {
- display: block; }
-
-/**
- * 1. Correct `inline-block` display not defined in IE 8/9.
- * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
- */
-audio,
-canvas,
-progress,
-video {
- display: inline-block;
- /* 1 */
- vertical-align: baseline;
- /* 2 */ }
-
-/**
- * Prevent modern browsers from displaying `audio` without controls.
- * Remove excess height in iOS 5 devices.
- */
-audio:not([controls]) {
- display: none;
- height: 0; }
-
-/**
- * Address `[hidden]` styling not present in IE 8/9/10.
- * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.
- */
-[hidden],
-template {
- display: none; }
-
-/* Links
- ========================================================================== */
-/**
- * Remove the gray background color from active links in IE 10.
- */
-a {
- background-color: transparent; }
-
-/**
- * Improve readability of focused elements when they are also in an
- * active/hover state.
- */
-a:active,
-a:hover {
- outline: 0; }
-
-/* Text-level semantics
- ========================================================================== */
-/**
- * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
- */
-abbr[title] {
- border-bottom: 1px dotted; }
-
-/**
- * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
- */
-b,
-strong {
- font-weight: bold; }
-
-/**
- * Address styling not present in Safari and Chrome.
- */
-dfn {
- font-style: italic; }
-
-/**
- * Address variable `h1` font-size and margin within `section` and `article`
- * contexts in Firefox 4+, Safari, and Chrome.
- */
-h1 {
- font-size: 2em;
- margin: 0.67em 0; }
-
-/**
- * Address styling not present in IE 8/9.
- */
-mark {
- background: #ff0;
- color: #000; }
-
-/**
- * Address inconsistent and variable font size in all browsers.
- */
-small {
- font-size: 80%; }
-
-/**
- * Prevent `sub` and `sup` affecting `line-height` in all browsers.
- */
-sub,
-sup {
- font-size: 75%;
- line-height: 0;
- position: relative;
- vertical-align: baseline; }
-
-sup {
- top: -0.5em; }
-
-sub {
- bottom: -0.25em; }
-
-/* Embedded content
- ========================================================================== */
-/**
- * Remove border when inside `a` element in IE 8/9/10.
- */
-img {
- border: 0; }
-
-/**
- * Correct overflow not hidden in IE 9/10/11.
- */
-svg:not(:root) {
- overflow: hidden; }
-
-/* Grouping content
- ========================================================================== */
-/**
- * Address margin not present in IE 8/9 and Safari.
- */
-figure {
- margin: 1em 40px; }
-
-/**
- * Address differences between Firefox and other browsers.
- */
-hr {
- box-sizing: content-box;
- height: 0; }
-
-/**
- * Contain overflow in all browsers.
- */
-pre {
- overflow: auto; }
-
-/**
- * Address odd `em`-unit font size rendering in all browsers.
- */
-code,
-kbd,
-pre,
-samp {
- font-family: monospace, monospace;
- font-size: 1em; }
-
-/* Forms
- ========================================================================== */
-/**
- * Known limitation: by default, Chrome and Safari on OS X allow very limited
- * styling of `select`, unless a `border` property is set.
- */
-/**
- * 1. Correct color not being inherited.
- * Known issue: affects color of disabled elements.
- * 2. Correct font properties not being inherited.
- * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
- */
-button,
-input,
-optgroup,
-select,
-textarea {
- color: inherit;
- /* 1 */
- font: inherit;
- /* 2 */
- margin: 0;
- /* 3 */ }
-
-/**
- * Address `overflow` set to `hidden` in IE 8/9/10/11.
- */
-button {
- overflow: visible; }
-
-/**
- * Address inconsistent `text-transform` inheritance for `button` and `select`.
- * All other form control elements do not inherit `text-transform` values.
- * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
- * Correct `select` style inheritance in Firefox.
- */
-button,
-select {
- text-transform: none; }
-
-/**
- * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
- * and `video` controls.
- * 2. Correct inability to style clickable `input` types in iOS.
- * 3. Improve usability and consistency of cursor style between image-type
- * `input` and others.
- */
-button,
-html input[type="button"],
-input[type="reset"],
-input[type="submit"] {
- -webkit-appearance: button;
- /* 2 */
- cursor: pointer;
- /* 3 */ }
-
-/**
- * Re-set default cursor for disabled elements.
- */
-button[disabled],
-html input[disabled] {
- cursor: not-allowed; }
-
-/**
- * Remove inner padding and border in Firefox 4+.
- */
-button::-moz-focus-inner,
-input::-moz-focus-inner {
- border: 0;
- padding: 0; }
-
-/**
- * Address Firefox 4+ setting `line-height` on `input` using `!important` in
- * the UA stylesheet.
- */
-input {
- line-height: normal; }
-
-/**
- * It's recommended that you don't attempt to style these elements.
- * Firefox's implementation doesn't respect box-sizing, padding, or width.
- *
- * 1. Address box sizing set to `content-box` in IE 8/9/10.
- * 2. Remove excess padding in IE 8/9/10.
- */
-input[type="checkbox"],
-input[type="radio"] {
- box-sizing: border-box;
- /* 1 */
- padding: 0;
- /* 2 */ }
-
-/**
- * Fix the cursor style for Chrome's increment/decrement buttons. For certain
- * `font-size` values of the `input`, it causes the cursor style of the
- * decrement button to change from `default` to `text`.
- */
-input[type="number"]::-webkit-inner-spin-button,
-input[type="number"]::-webkit-outer-spin-button {
- height: auto; }
-
-/**
- * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
- * 2. Address `box-sizing` set to `border-box` in Safari and Chrome.
- */
-input[type="search"] {
- -webkit-appearance: textfield;
- /* 1 */
- box-sizing: content-box;
- /* 2 */ }
-
-/**
- * Remove inner padding and search cancel button in Safari and Chrome on OS X.
- * Safari (but not Chrome) clips the cancel button when the search input has
- * padding (and `textfield` appearance).
- */
-input[type="search"]::-webkit-search-cancel-button,
-input[type="search"]::-webkit-search-decoration {
- -webkit-appearance: none; }
-
-/**
- * Define consistent border, margin, and padding.
- * [NOTE] We don't enable this ruleset in Foundation, because we want the