Added admin panel & improved web departures

This commit is contained in:
Filip Znachor 2022-12-19 21:37:44 +01:00
parent cd74f0982e
commit 3e9f1b80e3
7 changed files with 219 additions and 38 deletions

View file

@ -2,7 +2,7 @@ import threading
from wsgiref.simple_server import make_server, WSGIRequestHandler
from operator import itemgetter
from departures import Departure
from bottle import route, error, run, get, ServerAdapter, static_file
from bottle import route, error, run, get, ServerAdapter, static_file, request
class Server(ServerAdapter):
server = None
@ -34,13 +34,42 @@ class API:
return {'stops': main.config["stops"]}
@get("/static/<file>")
def index(file: str):
def static(file: str):
return static_file(file, root="static")
@get("/")
def static():
def index():
return static_file("index.html", root="static")
@get("/admin")
def admin():
return static_file("admin.html", root="static")
@route('/admin/devices', method='POST')
def admin_devices():
if main.config["admin"]["secret"] != request.forms.get('secret'):
return {'error': "unauthorized"}
return {'devices': main.controller.json()}
@route('/admin/devices/resend', method='POST')
def admin_devices_resend():
if main.config["admin"]["secret"] != request.forms.get('secret'):
return {'error': "unauthorized"}
for i, d in enumerate(main.controller.devices):
if d.stop_id and str(i) == request.forms.get('index'):
for dep in Departure.get(d.stop_id):
dep.resend(d.id)
return {'success': True}
@route('/admin/devices/clear', method='POST')
def admin_devices_clear():
if main.config["admin"]["secret"] != request.forms.get('secret'):
return {'error': "unauthorized"}
for i, d in enumerate(main.controller.devices):
if str(i) == request.forms.get('index'):
d.clear()
return {'success': True}
@error(404)
def error404(err):
return ''

12
server/static/admin.css Normal file
View file

@ -0,0 +1,12 @@
.devices {display: flex; flex-direction: column; gap: 10px;}
.devices > * {background-color: var(--alt-bg); border-radius: 8px; box-shadow: 0 2px 10px 0 #0005; border: 1px solid var(--border-color); overflow: hidden;}
.devices .header {cursor: pointer; padding: 15px; display: grid; grid-template-columns: 1fr max-content; user-select: none;}
.devices .id {font-weight: 700;}
.devices .settings {padding: 15px; border-top: 1px solid var(--border-color2); background: var(--alt-bg2); display: none;}
.devices .settings.visible {display: block;}
.devices .actions {display: flex; gap: 10px; flex-wrap: wrap; align-items: center;}
.devices .actions::before {content: "Rychlé akce:"; font-weight: 500; font-size: 90%; opacity: .5; text-transform: uppercase;}
.login form input {margin-bottom: 15px; display: block;}

39
server/static/admin.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<title>Administrace</title>
<link href="/static/style.css" rel="stylesheet">
<link href="/static/admin.css" rel="stylesheet">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div class="container" id="app">
<div v-if="logged">
<h2>Zařízení</h2>
<div class="devices">
<div v-for="d, i in devices">
<div class="header" @click="d.visible = !d.visible">
<div class="id">{{ d.id }}</div>
<div class="stop">{{ stops[d.stop_id] ? stops[d.stop_id].name : "Nenastaveno" }}</div>
</div>
<div class="settings" :class="{ visible: d.visible }">
<div class="actions">
<button @click="clear(i)">CLEAR</button>
<button @click="resend(i)">RESEND</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="login">
<h2>Přihlašte se</h2>
<form @submit.prevent="update()">
<input v-model="secret" type="password" placeholder="Heslo" />
<input type="submit" value="Přihlásit se" />
</form>
</div>
</div>
<script src="/static/admin.js"></script>
</body>
</html>

57
server/static/admin.js Normal file
View file

@ -0,0 +1,57 @@
const { createApp } = Vue;
let app = createApp({
data() {
return {
// logged: false,
// secret: "",
logged: true,
secret: "TajneHeslo",
stops: {},
devices: []
}
},
methods: {
async update() {
let devices = await api("/admin/devices", {secret: this.secret});
let stops = await api("/stops");
if(devices.error) {
alert("Neplatné heslo!");
return;
}
this.devices = devices.devices;
this.stops = stops.stops;
this.logged = true;
},
async clear(index) {
await api("/admin/devices/clear", {index, secret: this.secret});
},
async resend(index) {
await api("/admin/devices/resend", {index, secret: this.secret});
}
}
}).mount('#app');
async function api(url, data) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(!this.responseText) reject(this);
try {
resolve(JSON.parse(this.responseText));
} catch(e) {
resolve(null);
}
};
xhr.open(data ? "POST" : "GET", url, true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
var str = [];
for (var key in data) {
if (data.hasOwnProperty(key)) {
str.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key]))
}
}
xhr.send(str.join("&"));
});
}
app.update();

View file

@ -7,34 +7,50 @@ let app = createApp({
interval: null,
stop_id: null,
stops: [],
departures: []
departures: [],
filtered_departures: [],
filter: false
}
},
methods: {
async set() {
localStorage.setItem("favstop", app.$data.stop_id);
localStorage.setItem("favstop", this.stop_id);
this.update();
},
async update() {
if(app.$data.stop_id) app.$data.departures = (await api("/departures/"+app.$data.stop_id)).departures;
if(!this.stop_id) return;
let res = await api("/departures/"+this.stop_id);
let filtered_out = [];
this.departures = [];
this.filtered_departures = [];
for(let i=0; i<res.departures.length; i++) {
let d = res.departures[i];
if(d.departure < -.3) continue;
let hash = d.line + d.last_stop;
this.departures.push(d);
if(filtered_out.indexOf(hash) == -1) {
this.filtered_departures.push(d);
filtered_out.push(hash);
}
}
},
async setup() {
app.$data.stops = (await api("/stops")).stops;
let ids = Object.keys(app.$data.stops);
this.stops = (await api("/stops")).stops;
let ids = Object.keys(this.stops);
if(ids.length < 1) {
alert("Žádné zastávky nejsou k dispozici!");
if(app.$data.interval) clearTimeout(app.$data.interval);
if(this.interval) clearTimeout(this.interval);
return;
}
if(localStorage.getItem("favstop")) {
if(ids.indexOf(localStorage.getItem("favstop")) != -1) app.$data.stop_id = localStorage.getItem("favstop");
if(ids.indexOf(localStorage.getItem("favstop")) != -1) this.stop_id = localStorage.getItem("favstop");
}
if(!app.$data.stop_id) app.$data.stop_id = ids[0];
if(!this.stop_id) this.stop_id = ids[0];
window.addEventListener("scroll", () => {
app.$data.top = document.querySelector('html').scrollTop;
this.top = document.querySelector('html').scrollTop;
});
app.$data.interval = setInterval(app.update, 5000);
app.update();
this.interval = setInterval(this.update, 5000);
this.update();
}
}
}).mount('#app');

View file

@ -10,15 +10,25 @@
<div class="container" id="app">
<div class="nav" :class="{'top': top == 0}">
<div class="inner" v-if="stop_id && stops[stop_id]">
<h3>
<span>{{ stops[stop_id].name }}</span>
<svg class="icon" viewBox="0 0 24 24">
<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />
<div>
<h3>
<span>{{ stops[stop_id].name }}</span>
<svg class="icon icon-dropdown" viewBox="0 0 24 24">
<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />
</svg>
</h3>
<select v-model="stop_id" @change="set">
<option v-for="stop, id in stops" :value="id">{{ stop.name }}</option>
</select>
</div>
<div :title="filter ? 'Zobrazit všechny' : 'Zobrazit pouze nejbližší'" class="filter-toggle" @click="filter = !filter">
<svg class="icon" v-if="filter" viewBox="0 0 24 24">
<path d="M15,19.88C15.04,20.18 14.94,20.5 14.71,20.71C14.32,21.1 13.69,21.1 13.3,20.71L9.29,16.7C9.06,16.47 8.96,16.16 9,15.87V10.75L4.21,4.62C3.87,4.19 3.95,3.56 4.38,3.22C4.57,3.08 4.78,3 5,3V3H19V3C19.22,3 19.43,3.08 19.62,3.22C20.05,3.56 20.13,4.19 19.79,4.62L15,10.75V19.88M7.04,5L11,10.06V15.58L13,17.58V10.05L16.96,5H7.04Z" />
</svg>
</h3>
<select v-model="stop_id" @change="set">
<option v-for="stop, id in stops" :value="id">{{ stop.name }}</option>
</select>
<svg class="icon" v-else viewBox="0 0 24 24">
<path d="M2.39 1.73L1.11 3L9 10.89V15.87C8.96 16.16 9.06 16.47 9.29 16.7L13.3 20.71C13.69 21.1 14.32 21.1 14.71 20.71C14.94 20.5 15.04 20.18 15 19.88V16.89L20.84 22.73L22.11 21.46L15 14.35V14.34L13 12.35L11 10.34L4.15 3.5L2.39 1.73M6.21 3L8.2 5H16.96L13.11 9.91L15 11.8V10.75L19.79 4.62C20.13 4.19 20.05 3.56 19.62 3.22C19.43 3.08 19.22 3 19 3H6.21M11 12.89L13 14.89V17.58L11 15.58V12.89Z" />
</svg>
</div>
</div>
</div>
<div class="departure-grid">
@ -26,13 +36,17 @@
<div class="header">poslední zastávka</div>
<div class="header">zpoždění</div>
<div class="header">odjezd</div>
<template v-for="d in departures">
<template v-if="d.departure > -0.7">
<div class="line" :class="'type'+d.type">{{ d.line }}</div>
<div class="last-stop">{{ d.last_stop }}</div>
<div class="delay">{{ d.delay == 0 ? '' : '+'+d.delay }}</div>
<div class="departure">{{ d.departure < 1 ? '<1' : Math.floor(d.departure) }}</div>
</template>
<template v-if="!filter" v-for="d in departures">
<div class="line" :class="'type'+d.type">{{ d.line }}</div>
<div class="last-stop">{{ d.last_stop }}</div>
<div class="delay">{{ d.delay == 0 ? '' : '+'+d.delay }}</div>
<div class="departure">{{ d.departure < 1 ? '<1' : Math.floor(d.departure) }}</div>
</template>
<template v-else v-for="d in filtered_departures">
<div class="line" :class="'type'+d.type">{{ d.line }}</div>
<div class="last-stop">{{ d.last_stop }}</div>
<div class="delay">{{ d.delay == 0 ? '' : '+'+d.delay }}</div>
<div class="departure">{{ d.departure < 1 ? '<1' : Math.floor(d.departure) }}</div>
</template>
</div>
</div>

View file

@ -1,18 +1,23 @@
:root {
--site-bg: #1C1C1C;
--site-alt-bg: #272727;
--alt-bg: #303030;
--alt-bg2: #242424;
--button-bg: #454545;
--button-hover-bg: #3E3E3E;
--border-color: #3E3E3E;
--border-color2: #1f1f1f;
}
* {box-sizing: border-box;}
body {background-color: var(--site-bg); color: #fffd; margin: 0; margin-top: 35px; font-family: sans-serif;}
body {background-color: var(--site-bg); color: #fffd; margin: 0; margin-top: 35px; font-family: Ubuntu, sans-serif;}
.container {max-width: 700px; padding: 25px; margin: auto;}
.departure-grid {display: grid; grid-template-columns: 30px 1fr max-content max-content; gap: 10px 20px; line-height: 31px;}
.header {line-height: 100%; opacity: .5; margin-bottom: 10px; font-size: 90%;}
.departure-grid .header {line-height: 100%; opacity: .5; margin-bottom: 10px; font-size: 90%;}
.line {background-color: var(--site-alt-bg); border-radius: 3px; box-shadow: 0 0 5px 0 #0004; text-align: center; font-weight: 700; width: 30px; height: 30px; color: #fff; text-shadow: 0 0 15px #000;}
.line {background-color: var(--alt-bg); border-radius: 3px; box-shadow: 0 0 5px 0 #0004; text-align: center; font-weight: 700; width: 30px; height: 30px; color: #fff; text-shadow: 0 0 15px #000;}
.last-stop {font-weight: 700;}
.departure {text-align: right;}
.delay {text-align: right; color: #ff6e6e; font-size: 80%;}
@ -21,11 +26,20 @@ body {background-color: var(--site-bg); color: #fffd; margin: 0; margin-top: 35p
.line.type2 {background-color: #1E9641;}
.line.type3 {background-color: #CD2837;}
h3 {color: #fff; display: flex; align-items: center; gap: 5px;}
.icon {fill: #fff5; width: 24px; height: 24px;}
.icon {fill: #fff; width: 24px; height: 24px;}
.icon.icon-dropdown {fill: #fff5;}
.filter-toggle {cursor: pointer;}
.nav {position: fixed; top: 0; left: 0; width: 100%; z-index: 10;}
.nav .inner {background: linear-gradient(to bottom, var(--site-bg) 70%, transparent 100%); padding: 5px 25px; margin: auto; max-width: 700px; padding-bottom: 25px; transition: padding .1s; position: relative;}
.nav .inner {background: linear-gradient(to bottom, var(--site-bg) 70%, transparent 100%); padding: 20px 25px; margin: auto; max-width: 700px; padding-bottom: 30px; transition: padding .1s; display: grid; grid-template-columns: 1fr max-content; gap: 15px; align-items: center;}
.nav .inner > * {position: relative;}
.nav.top .inner {padding-bottom: 0px;}
.nav .inner::after {content: ""; position: fixed; bottom: 0; left: 0; height: 20px; width: 100%; background: linear-gradient(to top, var(--site-bg) 0%, transparent 100%)}
.nav .inner select {opacity: 0; float: left; position: relative; top: -60px; height: 50px; width: 100%; cursor: pointer;}
.nav .inner::after {content: ""; position: fixed; bottom: 0; left: 0; height: 20px; width: 100%; background: linear-gradient(to top, var(--site-bg) 0%, transparent 100%);}
.nav .inner h3 {margin: 0; color: #fff; display: flex; gap: 5px; font-size: 20px;}
.nav .inner select {opacity: 0; float: left; position: absolute; top: 0px; height: 110%; width: 100%; cursor: pointer;}
button, input {background: var(--button-bg); border: 0px; border-radius: 6px; color: #fff; padding: 10px 14px; font: inherit;}
button, input[type=submit] {cursor: pointer;}
:is(button, input[type=submit]):hover {background: var(--button-hover-bg);}