diff --git a/package-lock.json b/package-lock.json
index 7131bf3..b8657e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"materialize-css": "^1.0.0-rc.2",
"morgan": "~1.9.1",
"node-sass-middleware": "0.11.0",
+ "obs-websocket-js": "^4.0.2",
"pug": "2.0.0-beta11",
"socket.io": "^3.1.2"
},
@@ -1308,6 +1309,14 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
+ "node_modules/isomorphic-ws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz",
+ "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==",
+ "peerDependencies": {
+ "ws": "*"
+ }
+ },
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@@ -1726,6 +1735,38 @@
"node": ">=0.10.0"
}
},
+ "node_modules/obs-websocket-js": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-4.0.2.tgz",
+ "integrity": "sha512-e+tGp0DQNXSnitc5lfuzEC1dG3VdWy7VLePUVb6aq7bC33Sgjoi695k0eOg4UPTIQI71Z9aJ+yn3nvBX9dQkEg==",
+ "dependencies": {
+ "debug": "^4.1.0",
+ "isomorphic-ws": "^4.0.1",
+ "sha.js": "^2.4.9",
+ "ws": "^7.2.0"
+ }
+ },
+ "node_modules/obs-websocket-js/node_modules/debug": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+ "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/obs-websocket-js/node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -2332,6 +2373,18 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
"integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ=="
},
+ "node_modules/sha.js": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+ "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ },
+ "bin": {
+ "sha.js": "bin.js"
+ }
+ },
"node_modules/signal-exit": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
@@ -4042,6 +4095,12 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
+ "isomorphic-ws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz",
+ "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==",
+ "requires": {}
+ },
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@@ -4377,6 +4436,32 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
},
+ "obs-websocket-js": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-4.0.2.tgz",
+ "integrity": "sha512-e+tGp0DQNXSnitc5lfuzEC1dG3VdWy7VLePUVb6aq7bC33Sgjoi695k0eOg4UPTIQI71Z9aJ+yn3nvBX9dQkEg==",
+ "requires": {
+ "debug": "^4.1.0",
+ "isomorphic-ws": "^4.0.1",
+ "sha.js": "^2.4.9",
+ "ws": "^7.2.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+ "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ }
+ }
+ },
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -4877,6 +4962,15 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
"integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ=="
},
+ "sha.js": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+ "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"signal-exit": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
diff --git a/package.json b/package.json
index 59d6f1b..4e97c3d 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"materialize-css": "^1.0.0-rc.2",
"morgan": "~1.9.1",
"node-sass-middleware": "0.11.0",
+ "obs-websocket-js": "^4.0.2",
"pug": "2.0.0-beta11",
"socket.io": "^3.1.2"
},
diff --git a/public/javascripts/settings/config.js b/public/javascripts/settings/config.js
new file mode 100644
index 0000000..8f3fa50
--- /dev/null
+++ b/public/javascripts/settings/config.js
@@ -0,0 +1,67 @@
+import { formMaker, inputFieldMaker } from "/javascripts/tools/formMaker.js"
+const socket = io();
+const collapsible = document.querySelector(".collapsible");
+
+socket.on("connected", () => {
+ console.log("Connected !");
+ socket.emit("getType");
+});
+
+socket.on("getType", data => {
+ collapsible.innerHTML = "";
+ for (const type of data)
+ if (type.configuration) {
+ const [formRow, form] = formMaker();
+ for (const [name, conf] of Object.entries(type.configuration)) {
+ let secondRow = document.createElement("div");
+ secondRow.classList.add("row");
+ form.insertAdjacentElement("beforeend", secondRow);
+ secondRow.insertAdjacentElement("beforeend", inputFieldMaker(type.type, conf.type, name, conf));
+ }
+
+ const save = document.createElement("a");
+ save.classList.add("waves-effect", "waves-light", "btn", "blue");
+ save.innerText = "Save";
+ save.addEventListener("click", ev => {
+ ev.stopPropagation();
+ let data = {};
+ for (const e of new FormData(form))
+ data[e[0]] = e[1];
+ socket.emit("setTypeConfig", {type: type.type, configuration: data})
+ });
+ formRow.insertAdjacentElement("beforeend", save);
+
+ addCollapsible(type.name, formRow);
+ }
+ M.updateTextFields();
+});
+
+socket.on("setTypeConfig", data => {
+ if (data && data.configuration) {
+ for (const [name, value] of Object.entries(data.configuration))
+ document.getElementById(data.type + "-" + name).value = value;
+ M.updateTextFields();
+ }
+});
+
+function addCollapsible(title, content) {
+ const li = document.createElement("li");
+ const divTitle = document.createElement("div");
+ divTitle.classList.add("collapsible-header");
+ divTitle.innerText = title;
+ li.insertAdjacentElement("beforeend", divTitle);
+ const divContent = document.createElement("div");
+ divContent.classList.add("collapsible-body");
+ if (content)
+ if (typeof content !== "string")
+ divContent.insertAdjacentElement("beforeend", content);
+ else
+ divContent.insertAdjacentHTML("beforeend", content);
+ li.insertAdjacentElement("beforeend", divContent);
+ collapsible.insertAdjacentElement("beforeend", li);
+ return li;
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ M.AutoInit();
+});
diff --git a/public/javascripts/settings/decks.js b/public/javascripts/settings/decks.js
index 2ac9722..3d9ceb6 100644
--- a/public/javascripts/settings/decks.js
+++ b/public/javascripts/settings/decks.js
@@ -1,3 +1,5 @@
+import { inputFieldMaker } from "/javascripts/tools/formMaker.js"
+
const socket = io();
const deckSelect = document.getElementById("deck-select");
const deck = document.getElementById("deck");
@@ -217,37 +219,13 @@ function customFields(values) {
customs.innerHTML = "";
let t = types.find(v => v.type === type.value);
for (const [name, field] of Object.entries(t.fields)) {
- let e;
- switch (field.type) {
- case "text":
- e = document.createElement("input");
- e.type = "text";
- break;
- case "select":
- e = document.createElement("select");
- for (let option of field.options)
- e.insertAdjacentHTML("beforeend", ``);
- break;
- }
- e.name = name;
- e.id = name;
- e.required = true;
- e.classList.add("validate");
- let d = document.createElement("div");
- d.classList.add("input-field");
- d.insertAdjacentElement("beforeend", e);
- d.insertAdjacentHTML("beforeend", ``);
- if (field.helper)
- d.insertAdjacentHTML("beforeend", `${field.helper}`);
- customs.insertAdjacentElement("beforeend", d);
- if (values && name in values) {
- e.value = values[name];
- if (field.type === "select")
- e.querySelector(`option[value=${values[name]}]`).selected = true;
- }
+ if (values && name in values)
+ field.value = values[name];
+ customs.insertAdjacentElement("beforeend", inputFieldMaker(type.value, field.type, name, field));
if (field.type === "select") {
- M.FormSelect.init(e);
- e.style.display = "none";
+ const sel = customs.querySelector("select");
+ M.FormSelect.init(sel);
+ sel.style.display = "none";
}
}
M.updateTextFields();
diff --git a/public/javascripts/tools/formMaker.js b/public/javascripts/tools/formMaker.js
new file mode 100644
index 0000000..1db1011
--- /dev/null
+++ b/public/javascripts/tools/formMaker.js
@@ -0,0 +1,63 @@
+export function inputFieldMaker(Type, type, name, options) {
+ const inputField = document.createElement("div");
+ inputField.classList.add("input-field", "col", "s12");
+
+ let input;
+ switch (type) {
+ case "text":
+ input = document.createElement("input");
+ input.type = "text";
+ break;
+ case "select":
+ input = document.createElement("select");
+ for (const option of options.options) {
+ const opt = document.createElement("option");
+ opt.value = opt.innerText = option;
+ input.insertAdjacentElement("beforeend", opt);
+ }
+ break;
+ case "number":
+ input = document.createElement("input");
+ input.type = "number";
+ break;
+ case "password":
+ input = document.createElement("input");
+ input.type = "password";
+ break;
+ }
+ input.id = Type + "-" + name;
+ input.name = name;
+ input.classList.add("validate");
+ if (options.required)
+ input.required = true;
+ if (options.value) {
+ input.value = options.value;
+ if (type === "select")
+ input.querySelectorAll(`option[value=${options.value}]`).forEach(o => o.selected = true);
+ }
+
+ inputField.insertAdjacentElement("beforeend", input);
+
+ const label = document.createElement("label");
+ label.for = input.id;
+ label.innerText = name;
+ inputField.insertAdjacentElement("beforeend", label);
+
+ if (options.helper) {
+ const helper = document.createElement("span");
+ helper.classList.add("helper-text");
+ helper.innerText = options.helper;
+ inputField.insertAdjacentElement("beforeend", helper);
+ }
+
+ return inputField;
+}
+
+export function formMaker() {
+ const row = document.createElement("div");
+ row.classList.add("row");
+ const form = document.createElement("form");
+ form.classList.add("col", "s12");
+ row.insertAdjacentElement("beforeend", form);
+ return [row, form];
+}
diff --git a/routes/settings.js b/routes/settings.js
index 6b43665..b0297d3 100644
--- a/routes/settings.js
+++ b/routes/settings.js
@@ -1,8 +1,10 @@
const express = require("express");
const router = express.Router();
-router.get("/", function(req, res) {
- res.render("settings/decks", { title: "Settings"});
+router.get("/", (req, res) => {
+ res.render("settings/decks", { title: "Decks", path: "/settings"});
+}).get("/config", (req, res) => {
+ res.render("settings/config", {title:"Configuration", path: "/settings/config"})
});
module.exports = router;
diff --git a/sockets/index.js b/sockets/index.js
index 02811c7..daf170a 100644
--- a/sockets/index.js
+++ b/sockets/index.js
@@ -6,6 +6,7 @@ module.exports = socket => {
socket.on("setSlot", require("./setSlot")(socket));
socket.on("addDeck", require("./addDeck")(socket));
socket.on("deleteDeck", require("./deleteDeck")(socket));
+ socket.on("setTypeConfig", require("./setTypeConfig")(socket));
socket.on("trigger", require("./trigger")(socket));
socket.on("uploadImage", require("./uploadImage")(socket));
console.log("New connection !");
diff --git a/sockets/setTypeConfig.js b/sockets/setTypeConfig.js
new file mode 100644
index 0000000..b661684
--- /dev/null
+++ b/sockets/setTypeConfig.js
@@ -0,0 +1,21 @@
+const { types } = require("../types");
+
+
+module.exports = socket => {
+ return data => {
+ if (data.type && data.configuration) {
+ try {
+ const type = types[data.type], config = type.config();
+ for (const [name, value] of Object.entries(data.configuration))
+ config[name] = value;
+ type.saveConfig(config);
+ } catch (err) {
+ console.error(err);
+ socket.emit("setTypeConfig", {error: err.code});
+ return;
+ }
+ }
+ socket.emit("setTypeConfig", data);
+ socket.broadcast.emit("setTypeConfig", data);
+ }
+};
diff --git a/types/Base.js b/types/Base.js
index 8b6a9f2..852abca 100644
--- a/types/Base.js
+++ b/types/Base.js
@@ -21,27 +21,33 @@ class Base {
if (!(position[0] in db.decks[name].rows))
db.decks[name].rows[position[0]] = {};
db.decks[name].rows[position[0]][position[1]] = this.toJSON();
- Base.#write()
+ Base.write()
+ }
+
+ static saveConfig(type, configuration) {
+ db.types[type] = configuration;
+ Base.write();
}
remove(name, position) {
if (position[0] in db.decks[name].rows && position[1] in db.decks[name].rows[position[0]]) {
delete db.decks[name].rows[position[0]][position[1]];
- Base.#write();
+ Base.write();
return true;
}
return false;
}
- static #write() {
+ static write() {
fs.writeFileSync("./db.json", JSON.stringify(db));
}
- static staticToJSON(name, type, fields) {
+ static staticToJSON(name, type, fields, config) {
return {
"name": name,
"type": type,
- "fields": fields
+ "fields": fields,
+ "configuration": config
}
}
diff --git a/types/Deck.js b/types/Deck.js
index 3d8b9a8..7ddfefa 100644
--- a/types/Deck.js
+++ b/types/Deck.js
@@ -13,9 +13,10 @@ class Deck extends Base {
static fields() {
return {
deck: {
- "type": "select",
- "options": Object.keys(db.decks),
- "name": "Deck"
+ type: "select",
+ options: Object.keys(db.decks),
+ name: "Deck",
+ required: true,
}
};
}
diff --git a/types/ExecCommand.js b/types/ExecCommand.js
index 68a0afe..dc6e453 100644
--- a/types/ExecCommand.js
+++ b/types/ExecCommand.js
@@ -7,7 +7,8 @@ class ExecCommand extends Base {
static fields = {
cmd: {
type: "text",
- name: "Command"
+ name: "Command",
+ required: true
}
};
diff --git a/types/Keys.js b/types/Keys.js
index 354f3cc..c864b11 100644
--- a/types/Keys.js
+++ b/types/Keys.js
@@ -9,6 +9,7 @@ class Keys extends Base {
keys: {
type: "text",
name: "Keys",
+ required: true,
helper: "Key separated by a comma, if combo use +"
}
};
diff --git a/types/OBS.js b/types/OBS.js
new file mode 100644
index 0000000..cfeeaba
--- /dev/null
+++ b/types/OBS.js
@@ -0,0 +1,155 @@
+const Base = require("./Base");
+const OBSWebSocket = require('obs-websocket-js');
+const db = require("../db.json");
+
+class OBS extends Base {
+ static name = "OBS";
+ static type = "obs";
+ static fields = {
+ cmd: {
+ type: "select",
+ options: ["startStreaming", "stopStreaming", "toggleStreaming", "startRecording", "stopRecording", "toggleRecording"],
+ name: "Action",
+ required: true
+ }
+ };
+ static obs = new OBSWebSocket();
+
+ constructor(text, image = null, options = null) {
+ super(text, image, options);
+ }
+
+ static config() {
+ return {
+ host: {
+ type: "text",
+ name: "Address",
+ required: true,
+ value: db.types[OBS.type].host,
+ },
+ port: {
+ type: "number",
+ name: "Port",
+ required: true,
+ value: db.types[OBS.type].port,
+ },
+ password: {
+ type: "password",
+ name: "password",
+ value: db.types[OBS.type].password
+ }
+ }
+ };
+
+ /**
+ * @override
+ */
+ static saveConfig(configuration) {
+ super.saveConfig(OBS.type, configuration)
+ }
+
+ /**
+ * @override
+ */
+ static staticToJSON() {
+ return super.staticToJSON(OBS.name, OBS.type, OBS.fields, OBS.config());
+ }
+
+ /**
+ * @override
+ */
+ toJSON() {
+ return super.toJSON(OBS.type)
+ }
+
+ static async connect() {
+ const config = OBS.config();
+ try {
+ await OBS.obs.connect({
+ address: config.host.value + ":" + config.port.value,
+ password: config.password.value
+ });
+ return true;
+ } catch (err) {
+ console.error("Fail to connect !");
+ console.error(err);
+ return false;
+ }
+ }
+
+ /**
+ * @override
+ */
+ async trigger(socket) {
+ try {
+ switch (this.options.cmd) {
+ case "startStreaming":
+ await OBS.obs.send("StartStreaming");
+ break;
+ case "stopStreaming":
+ await OBS.obs.send("StopStreaming");
+ break;
+ case "toggleStreaming":
+ try {
+ await OBS.obs.send("StartStreaming");
+ } catch (err) {
+ if (err.error === "streaming already active")
+ await OBS.obs.send("StopStreaming");
+ else
+ throw err;
+ }
+ break;
+ case "startRecording":
+ await OBS.obs.send("StartRecording");
+ break;
+ case "stopRecording":
+ await OBS.obs.send("StopRecording");
+ break;
+ case "toggleRecording":
+ try {
+ await OBS.obs.send("StartRecording");
+ } catch (err) {
+ if (err.error === "recording already active")
+ await OBS.obs.send("StopRecording");
+ else
+ throw err;
+ }
+ break;
+ }
+ } catch (err) {
+ switch (err.error) {
+ case "There is no Socket connection available.":
+ if (!await OBS.connect())
+ socket.emit("trigger", {error: "failToConnectOBS"});
+ else
+ this.trigger(socket);
+ break;
+ case "streaming already active":
+ console.error("Streaming already active");
+ break;
+ case "streaming not active":
+ console.error("Streaming not active");
+ break;
+ case "recording already active":
+ console.error("Recording already active");
+ break;
+ case "recording not active":
+ console.error("Recording not active");
+ break;
+ default:
+ throw err;
+ }
+ }
+ }
+}
+
+if (!(OBS.type in db.types)) {
+ db.types[OBS.type] = {};
+ OBS.write();
+}
+
+OBS.obs.on("error", err => {
+ console.error("socket error:", err);
+});
+
+module.exports = OBS;
diff --git a/types/index.js b/types/index.js
index 60bcde2..894a03b 100644
--- a/types/index.js
+++ b/types/index.js
@@ -3,7 +3,8 @@ const db = require("../db.json");
const types = {
"execCommand": require("./ExecCommand"),
"deck": require("./Deck"),
- "keys": require("./Keys")
+ "keys": require("./Keys"),
+ "obs": require("./OBS")
};
module.exports.types = types;
diff --git a/views/settings/config.pug b/views/settings/config.pug
new file mode 100644
index 0000000..0c5838f
--- /dev/null
+++ b/views/settings/config.pug
@@ -0,0 +1,7 @@
+extends ./settings
+
+block settings
+ .container
+ ul.collapsible
+
+ script(src="/javascripts/settings/config.js" type="module")
diff --git a/views/settings/decks.pug b/views/settings/decks.pug
index 8980afb..e3f8403 100644
--- a/views/settings/decks.pug
+++ b/views/settings/decks.pug
@@ -1,9 +1,10 @@
extends ./settings
block settings
- .input-field
- select#deck-select
- label Deck
+ .container
+ .input-field
+ select#deck-select
+ label Deck
.container#deck
@@ -70,4 +71,4 @@ block settings
button(data-target="modalAdd")#clearAdd.modal-close.waves-effect.waves-grey.btn-flat Cancel
a#add.waves-effect.waves-green.btn-flat Save
- script(src="/javascripts/settings/decks.js")
+ script(src="/javascripts/settings/decks.js" type="module")
diff --git a/views/settings/settings.pug b/views/settings/settings.pug
index 3d84d22..e94e01b 100644
--- a/views/settings/settings.pug
+++ b/views/settings/settings.pug
@@ -5,6 +5,8 @@ block content
.nav-wrapper
a.brand-logo.right(href="/") OpenDeck
ul#nav-mobile.left.hide-on-med-and-down
- li
+ li(class=path==="/settings"? "active": "")
a(href="/settings") Decks
+ li(class=path==="/settings/config"? "active": "")
+ a(href="/settings/config") Configuration
block settings