Added menu and expandable filters panel

This commit is contained in:
Jan Zípek 2022-08-26 09:34:15 +02:00
parent 50e56453da
commit 7491e85ecd
Signed by: kamen
GPG Key ID: A17882625B33AC31
26 changed files with 566 additions and 194 deletions

202
client/package-lock.json generated
View File

@ -23,6 +23,7 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.30.0",
"prettier": "^2.5.1",
"sass": "^1.54.5",
"ts-node": "^8.10.1",
"typescript": "^4.3.2",
"vite": "^3.0.9",
@ -1352,6 +1353,19 @@
"node": ">=4"
}
},
"node_modules/anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@ -1476,6 +1490,15 @@
"node": ">=0.6"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/binary-search-bounds": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz",
@ -1641,6 +1664,45 @@
"node": ">=4"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/clamp": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz",
@ -3869,6 +3931,12 @@
"quantize": "^1.0.2"
}
},
"node_modules/immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"dev": true
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -3945,6 +4013,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-blob": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-blob/-/is-blob-2.1.0.tgz",
@ -4616,6 +4696,15 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-svg-path": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz",
@ -5225,6 +5314,18 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
@ -5461,6 +5562,23 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sass": {
"version": "1.54.5",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.5.tgz",
"integrity": "sha512-p7DTOzxkUPa/63FU0R3KApkRHwcVZYC0PLnLm5iyZACyp15qSi32x7zVUhRdABAATmkALqgGrjCJAcWvobmhHw==",
"dev": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
@ -7271,6 +7389,16 @@
"color-convert": "^1.9.0"
}
},
"anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@ -7369,6 +7497,12 @@
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg=="
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
"binary-search-bounds": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz",
@ -7490,6 +7624,33 @@
"supports-color": "^5.3.0"
}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"fsevents": "~2.3.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"dependencies": {
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
}
}
},
"clamp": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz",
@ -9199,6 +9360,12 @@
"quantize": "^1.0.2"
}
},
"immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"dev": true
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -9260,6 +9427,15 @@
"has-bigints": "^1.0.1"
}
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-blob": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-blob/-/is-blob-2.1.0.tgz",
@ -9767,6 +9943,12 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"normalize-svg-path": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz",
@ -10227,6 +10409,15 @@
}
}
},
"readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"requires": {
"picomatch": "^2.2.1"
}
},
"regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
@ -10411,6 +10602,17 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sass": {
"version": "1.54.5",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.5.tgz",
"integrity": "sha512-p7DTOzxkUPa/63FU0R3KApkRHwcVZYC0PLnLm5iyZACyp15qSi32x7zVUhRdABAATmkALqgGrjCJAcWvobmhHw==",
"dev": true,
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
}
},
"sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",

View File

@ -19,6 +19,7 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.30.0",
"prettier": "^2.5.1",
"sass": "^1.54.5",
"ts-node": "^8.10.1",
"typescript": "^4.3.2",
"vite": "^3.0.9",

View File

@ -0,0 +1,20 @@
.dashboard-head {
display: flex;
align-items: center;
margin-left: auto;
.spacer {
margin: 0 1rem;
width: 1px;
background: var(--header-spacer-color);
height: 20px;
}
.filter-button {
font-size: 125%;
cursor: pointer;
display: flex;
align-items: center;
margin-left: 0.5rem;
}
}

View File

@ -0,0 +1,27 @@
.filters-panel {
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: 2;
width: 20rem;
display: flex;
.inner {
background-color: var(--box-bg-color);
padding: 0.25rem 0.5rem;
flex: 1;
}
.shadow {
background: var(--filters-menu-shadow);
width: 8px;
flex-shrink: 0;
flex-grow: 0;
}
.filter-close {
cursor: pointer;
font-size: 150%;
}
}

View File

@ -0,0 +1 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@ -0,0 +1 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path></svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@ -0,0 +1 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>

After

Width:  |  Height:  |  Size: 224 B

View File

@ -1,9 +1,9 @@
:root {
--main-bg-color: #eee;
--main-bg-color: #eee;
--main-fg-color: #000;
--button-bg-color: #3988FF;
--button-bg-color: #3988ff;
--button-fg-color: #fff;
--button-hover-bg-color: #0F6FFF;
--button-hover-bg-color: #0f6fff;
--button-hover-fg-color: #fff;
--button-cancel-bg-color: transparent;
--button-cancel-fg-color: #000;
@ -11,24 +11,27 @@
--button-remove-fg-color: #f00;
--header-bg-color: #fff;
--header-spacer-color: #ddd;
--header-shadow: linear-gradient(0deg, rgba(255,255,255,0) 5%, rgba(190,190,190,0.6) 100%);
--header-shadow: linear-gradient(0deg, rgba(255, 255, 255, 0) 5%, rgba(190, 190, 190, 0.6) 100%);
--box-bg-color: #fff;
--box-fg-color: #111;
--box-loader-bg-color: rgba(128, 128, 128, 0.3);
--box-loader-fg-color: #fff;
--box-action-fg-color: #666;
--box-preview-bg-color: #3988FF;
--box-shadow: 0px 10px 15px -3px rgba(0,0,0,0.1);
--modal-overlay-bg-color: rgba(0,0,0,0.2);
--box-preview-bg-color: #3988ff;
--box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.1);
--modal-overlay-bg-color: rgba(0, 0, 0, 0.2);
--graph-axis-fg-color: #777;
--graph-grid-color: rgb(238, 238, 238);
--link-fg-color: #3988ff;
--menu-shadow: linear-gradient(270deg, rgba(255, 255, 255, 0) 0%, rgba(90, 90, 90, 0.2) 100%);
--filters-menu-shadow: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(90, 90, 90, 0.2) 100%);
}
@media (prefers-color-scheme: dark) {
:root {
--main-bg-color: #222;
--main-fg-color: #b8b8b8;
--header-bg-color: #000;
--main-fg-color: #ccc;
--header-bg-color: #111;
--header-spacer-color: #333;
--header-shadow: none;
--box-bg-color: #111;
@ -36,10 +39,24 @@
--box-shadow: none;
--graph-axis-fg-color: #666;
--graph-grid-color: rgb(25, 25, 25);
--button-bg-color: #0b3c9f;
--button-fg-color: #eee;
--button-cancel-fg-color: #ccc;
}
select,
input {
border: 1px solid #333;
background: #222;
color: #ccc;
border-radius: 0.25rem;
box-sizing: border-box;
padding: 0.1rem 0.25rem;
}
}
body, html {
body,
html {
padding: 0;
margin: 0;
width: 100%;
@ -49,11 +66,13 @@ body, html {
body {
background: var(--main-bg-color);
color: var(--main-fg-color);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
button, input, select {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
button,
input,
select {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
button {
@ -72,11 +91,16 @@ button:hover {
color: var(--button-hover-fg-color);
}
input, select {
input,
select {
padding: 0.15rem 0.4rem;
box-sizing: border-box;
}
a {
color: var(--link-fg-color);
}
.login {
width: 300px;
margin: 2rem auto;
@ -93,6 +117,95 @@ input, select {
flex-direction: column;
}
main.layout {
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
}
.menu {
position: fixed;
left: 0;
top: 0;
bottom: 0;
color: var(--box-fg-color);
width: 20rem;
transition: left 0.1s;
z-index: 2;
display: flex;
}
.menu .inner {
background-color: var(--box-bg-color);
flex: 1;
padding: 0.25em 0.5rem;
}
.menu .shadow {
width: 8px;
background: var(--menu-shadow);
flex-shrink: 0;
flex-grow: 0;
}
.menu .menu-close {
font-size: 150%;
cursor: pointer;
}
.menu nav {
display: flex;
flex-direction: column;
}
.menu nav a {
text-decoration: none;
}
.menu-overlay {
background-color: var(--modal-overlay-bg-color);
z-index: 1;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
header.header {
flex-grow: 0;
flex-shrink: 0;
}
header.header > .inner {
display: flex;
padding: 0.5rem 0.5rem;
background-color: var(--header-bg-color);
}
header.header > .shadow {
position: absolute;
background: var(--header-shadow);
width: 100%;
height: 8px;
z-index: 1;
}
header.header > .inner > .menu-button {
display: flex;
align-items: center;
font-size: 125%;
cursor: pointer;
}
section.content {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.sensors {
display: grid;
grid-template-columns: 1fr 1fr;
@ -103,9 +216,9 @@ input, select {
}
@media only screen and (max-width: 1200px) {
.sensors {
grid-template-columns: 1fr;
}
.sensors {
grid-template-columns: 1fr;
}
}
.box {
@ -168,6 +281,10 @@ form .input {
margin-bottom: 0.5rem;
}
form .input label {
margin-bottom: 0.2rem;
}
form .actions {
text-align: right;
margin-top: 1rem;
@ -198,35 +315,12 @@ form.horizontal .input label {
margin-right: 0.25rem;
}
.dashboard-head .inner {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.5rem 0.5rem;
background-color: var(--header-bg-color);
}
.dashboard-head .shadow {
position: absolute;
background: var(--header-shadow);
width: 100%;
height: 8px;
z-index: 1;
}
.dashboard-head .spacer {
margin: 0 1rem;
width: 1px;
background: var(--header-spacer-color);
height: 20px;
}
.checkbox-label {
display: inline-flex;
align-items: center;
}
.checkbox-label input[type=checkbox] {
.checkbox-label input[type="checkbox"] {
margin-top: 6px;
}
@ -235,7 +329,7 @@ form.horizontal .input label {
padding: 0.25rem;
flex: 1;
overflow: auto;
min-height: 0;
min-height: 0;
}
.grid-sensors {
@ -308,7 +402,7 @@ form.horizontal .input label {
.grid-sensors .grid-box .box .resize {
position: absolute;
right: 0;;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
@ -394,6 +488,13 @@ form.horizontal .input label {
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(-360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}
@import "components/dashboard-header";
@import "components/filters-panel";

View File

@ -1,2 +1,6 @@
export { ReactComponent as SettingsIcon } from '@/assets/icons/settings.svg'
export { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg'
export { ReactComponent as MenuIcon } from '@/assets/icons/menu.svg'
export { ReactComponent as CancelIcon } from '@/assets/icons/cancel.svg'
export { ReactComponent as FiltersIcon } from '@/assets/icons/filters.svg'
export { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'

View File

@ -3,7 +3,7 @@
import { Root } from './Root'
import { render } from 'preact'
import './assets/style.css'
import './assets/style.scss'
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
render(<Root />, document.getElementById('application')!)

View File

@ -0,0 +1,31 @@
import { MenuIcon } from '@/icons'
import { cn } from '@/utils/cn'
import { ComponentChild } from 'preact'
import { useState } from 'preact/hooks'
import { UserMenu } from './components/UserMenu'
type Props = {
children: ComponentChild
header?: ComponentChild
className?: string
}
export const UserLayout = ({ children, header, className }: Props) => {
const [menuShown, setMenuShown] = useState(false)
return (
<main className={cn('layout', className)}>
<header className={'header'}>
<div className="inner">
<div className="menu-button" onClick={() => setMenuShown(true)}>
<MenuIcon />
</div>
{header}
</div>
<div className="shadow"></div>
</header>
<section className="content">{children}</section>
<UserMenu shown={menuShown} onHide={() => setMenuShown(false)} />
</main>
)
}

View File

@ -0,0 +1,28 @@
import { CancelIcon } from '@/icons'
type Props = {
shown: boolean
onHide: () => void
}
export const UserMenu = ({ shown, onHide }: Props) => {
return (
<>
<div className="menu" style={{ left: !shown ? '-20rem' : '0' }}>
<div className="inner">
<div className="menu-close" onClick={onHide}>
<CancelIcon />
</div>
<nav>
<a href="#">Dashboards</a>
<a href="#">Sensors</a>
<a href="#">Settings</a>
</nav>
</div>
<div className="shadow"></div>
</div>
{shown && <div className="menu-overlay" onClick={onHide}></div>}
</>
)
}

View File

@ -3,12 +3,13 @@ import {
getDashboards,
updateDashboard,
} from '@/api/dashboards'
import { UserLayout } from '@/layouts/UserLayout/UserLayout'
import { createDashboardContent } from '@/utils/createDashboardContent'
import { parseDashboard } from '@/utils/parseDashboard'
import { useEffect, useMemo, useState } from 'preact/hooks'
import { useQuery, useQueryClient } from 'react-query'
import { DashboardGrid } from './components/DashboardGrid'
import { DashboardHeader } from './components/DashboardHeader'
import { DashboardGrid } from './components/DashboardGrid/DashboardGrid'
import { DashboardHeader } from './components/DashboardHeader/DashboardHeader'
import { GRID_H_SNAP, GRID_WIDTH } from './constants'
import { DashboardContextProvider } from './contexts/DashboardContext'
import { BoxDefinition } from './types'
@ -82,10 +83,14 @@ export const NewDashboardPage = () => {
return (
<DashboardContextProvider>
<div className="dashboard">
<DashboardHeader onRefresh={handleRefresh} onNewBox={handleNewBox} />
<UserLayout
className="dashboard"
header={
<DashboardHeader onRefresh={handleRefresh} onNewBox={handleNewBox} />
}
>
<DashboardGrid boxes={boxes} onChange={handleChange} />
</div>
</UserLayout>
</DashboardContextProvider>
)
}

View File

@ -1,6 +1,6 @@
import { BoxDefinition } from '../types'
import { normalizeBoxes } from '../utils/normalizeBoxes'
import { EditableBox } from './EditableBox'
import { BoxDefinition } from '../../types'
import { normalizeBoxes } from '../../utils/normalizeBoxes'
import { EditableBox } from './components/EditableBox'
type Props = {
boxes: BoxDefinition[]

View File

@ -3,8 +3,8 @@ import { DashboardDialData } from '@/utils/parseDashboard'
import { RefObject } from 'preact'
import { useMemo } from 'preact/hooks'
import { useQuery } from 'react-query'
import { useDashboardContext } from '../contexts/DashboardContext'
import { BoxDefinition } from '../types'
import { useDashboardContext } from '../../../contexts/DashboardContext'
import { BoxDefinition } from '../../../types'
import { BoxLoader } from './BoxLoader'
type Props = {

View File

@ -1,12 +1,12 @@
import { getSensorValues } from '@/api/sensorValues'
import { useDashboardContext } from '@/pages/dashboard/contexts/DashboardContext'
import { BoxDefinition } from '@/pages/dashboard/types'
import { max } from '@/utils/max'
import { min } from '@/utils/min'
import { DashboardGraphData } from '@/utils/parseDashboard'
import { RefObject } from 'preact'
import { useEffect, useRef } from 'preact/hooks'
import { useQuery } from 'react-query'
import { useDashboardContext } from '../contexts/DashboardContext'
import { BoxDefinition } from '../types'
import { BoxLoader } from './BoxLoader'
type Props = {

View File

@ -1,15 +1,15 @@
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
import { useRef, useState } from 'preact/hooks'
import { GRID_WIDTH } from '../constants'
import { useDashboardContext } from '../contexts/DashboardContext'
import { useDragging } from '../hooks/useDragging'
import { ResizingMode, useResize } from '../hooks/useResize'
import { BoxDefinition } from '../types'
import { BoxDialContent } from './BoxDialContent'
import { BoxGraphContent } from './BoxGraphContent'
import { BoxSettings } from './BoxSettings/BoxSettings'
import { useElementOffsets } from '@/utils/hooks/useElementOffsets'
import { RefreshIcon, SettingsIcon } from '@/icons'
import { BoxDefinition } from '@/pages/dashboard/types'
import { GRID_WIDTH } from '@/pages/dashboard/constants'
import { useDashboardContext } from '@/pages/dashboard/contexts/DashboardContext'
import { useDragging } from '@/pages/dashboard/hooks/useDragging'
import { useResize, ResizingMode } from '@/pages/dashboard/hooks/useResize'
import { BoxSettings } from '../../BoxSettings/BoxSettings'
type Props = {
box: BoxDefinition
@ -54,6 +54,10 @@ export const EditableBox = ({
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
if (verticalMode) {
return
}
if (!dragging.active && boxRef) {
const pos = {
top: boxRef.offsetTop,
@ -74,6 +78,10 @@ export const EditableBox = ({
e.preventDefault()
e.stopPropagation()
if (verticalMode) {
return
}
if (resizing.mode === ResizingMode.NONE) {
setResizing({
mode: target,

View File

@ -1,24 +0,0 @@
import { RefreshIcon } from '@/icons'
import { Filters } from './Filters'
type Props = {
onNewBox: () => void
onRefresh: () => void
}
export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => {
return (
<div className="dashboard-head">
<div className="inner">
<button onClick={onNewBox}>Add box</button>
<div className="spacer" />
<Filters />
<div className="spacer" />
<button onClick={onRefresh}>
<RefreshIcon /> Refresh all
</button>
</div>
<div className="shadow"></div>
</div>
)
}

View File

@ -0,0 +1,59 @@
import { CancelIcon, FiltersIcon, PlusIcon, RefreshIcon } from '@/icons'
import { useState } from 'preact/hooks'
import { useDashboardContext } from '../../contexts/DashboardContext'
import { DashboardFilters } from './components/DashboardFilters'
type Props = {
onNewBox: () => void
onRefresh: () => void
}
export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => {
const { verticalMode } = useDashboardContext()
const [filtersShown, setFiltersShown] = useState(false)
return (
<div className="dashboard-head">
{verticalMode && (
<>
<button onClick={onNewBox}>
<PlusIcon />
</button>
<button onClick={onRefresh}>
<RefreshIcon />
</button>
<div className="filter-button" onClick={() => setFiltersShown(true)}>
<FiltersIcon />
</div>
{filtersShown && (
<div className="filters-panel">
<div className="shadow" />
<div className="inner">
<div
className="filter-close"
onClick={() => setFiltersShown(false)}
>
<CancelIcon />
</div>
<DashboardFilters />
</div>
</div>
)}
</>
)}
{!verticalMode && (
<>
<button onClick={onNewBox}>Add box</button>
<div className="spacer" />
<DashboardFilters />
<div className="spacer" />
<button onClick={onRefresh}>
<RefreshIcon /> Refresh all
</button>
</>
)}
</div>
)
}

View File

@ -1,7 +1,7 @@
import { DateTimeInput } from '@/components/DateTimeInput'
import { intervalToRange } from '@/utils/intervalToRange'
import { useEffect, useState } from 'preact/hooks'
import { useDashboardContext } from '../contexts/DashboardContext'
import { useDashboardContext } from '../../../contexts/DashboardContext'
export type FilterInterval =
| 'hour'
@ -17,8 +17,8 @@ export type FilterValue = {
customTo: Date
}
export const Filters = () => {
const { filter: preset, setFilter } = useDashboardContext()
export const DashboardFilters = () => {
const { filter: preset, setFilter, verticalMode } = useDashboardContext()
const [value, setValue] = useState(preset)
@ -50,7 +50,10 @@ export const Filters = () => {
return (
<div className="filter-form">
<form className="horizontal" onSubmit={handleSubmit}>
<form
className={!verticalMode ? 'horizontal' : undefined}
onSubmit={handleSubmit}
>
<div className="input">
<label>Interval</label>
<select

View File

@ -1,99 +0,0 @@
import { SensorInfo } from '@/api/sensors'
import { getSensorValues } from '@/api/sensorValues'
import { useEffect, useRef } from 'preact/hooks'
import { useQuery } from 'react-query'
import { FilterValue } from './Filters'
type Props = {
sensor: SensorInfo
filter: FilterValue
onEdit: (sensor: SensorInfo) => void
}
export const Sensor = ({ sensor, filter, onEdit }: Props) => {
const bodyRef = useRef<HTMLDivElement>(null)
const valuesQuery = {
sensor: sensor.sensor,
from: filter.customFrom,
to: filter.customTo,
}
const values = useQuery(['/sensor/values', valuesQuery], () =>
getSensorValues(valuesQuery)
)
useEffect(() => {
// TODO: These should be probably returned by server, could be outdated
const from = filter.customFrom
const to = filter.customTo
const minValue = parseFloat(sensor.config.min)
const maxValue = parseFloat(sensor.config.max)
const customRange = !isNaN(minValue) && !isNaN(maxValue)
if (bodyRef.current && values.data) {
window.Plotly.newPlot(
bodyRef.current,
[
{
...(sensor.config.graphType === 'line' && {
type: 'scatter',
mode: 'lines',
}),
...(sensor.config.graphType === 'points' && {
type: 'scatter',
mode: 'markers',
}),
...(sensor.config.graphType === 'lineAndPoints' && {
type: 'scatter',
mode: 'lines+markers',
}),
...(sensor.config.graphType === 'bar' && { type: 'bar' }),
x: values.data.map((v) => new Date(v.timestamp * 1000)),
y: values.data.map((v) => v.value),
line: {
width: 1,
},
},
],
{
xaxis: { range: [from, to], type: 'date' },
yaxis: {
...(customRange && { range: [minValue, maxValue] }),
...(sensor.config.unit && { ticksuffix: ` ${sensor.config.unit}` }),
},
margin: {
l: 70,
r: 20,
b: 60,
t: 20,
pad: 5,
},
height: 300,
},
{
responsive: true,
}
)
}
}, [values.data, sensor.config])
return (
<div className="sensor">
<div className="header">
<div className="name">{sensor.config?.name ?? sensor.sensor}</div>
<div className="actions">
<button className="config" onClick={() => onEdit(sensor)}>
Config
</button>
<button className="refresh" onClick={() => values.refetch()}>
Refresh
</button>
</div>
</div>
<div className="body">
<div ref={bodyRef} />
</div>
</div>
)
}

View File

@ -2,7 +2,7 @@ import { useViewportSize } from '@/utils/hooks/useViewportSize'
import { intervalToRange } from '@/utils/intervalToRange'
import { ComponentChild, createContext } from 'preact'
import { StateUpdater, useContext, useMemo, useState } from 'preact/hooks'
import { FilterValue } from '../components/Filters'
import { FilterValue } from '../components/DashboardHeader/components/DashboardFilters'
type DashboardContextType = {
filter: FilterValue

2
client/src/utils/cn.ts Normal file
View File

@ -0,0 +1,2 @@
export const cn = (...cns: (string | undefined | null | boolean)[]) =>
cns.filter(Boolean).join(' ')

View File

@ -1,4 +1,4 @@
import { FilterInterval } from '@/pages/dashboard/components/Filters'
import { FilterInterval } from '@/pages/dashboard/components/DashboardHeader/components/DashboardFilters'
export const intervalToRange = (
interval: FilterInterval,