Added admin panel & improved web departures
This commit is contained in:
parent
cd74f0982e
commit
3e9f1b80e3
|
@ -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
12
server/static/admin.css
Normal 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
39
server/static/admin.html
Normal 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
57
server/static/admin.js
Normal 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();
|
|
@ -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');
|
||||
|
|
|
@ -10,9 +10,10 @@
|
|||
<div class="container" id="app">
|
||||
<div class="nav" :class="{'top': top == 0}">
|
||||
<div class="inner" v-if="stop_id && stops[stop_id]">
|
||||
<div>
|
||||
<h3>
|
||||
<span>{{ stops[stop_id].name }}</span>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<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>
|
||||
|
@ -20,19 +21,32 @@
|
|||
<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>
|
||||
<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">
|
||||
<div class="header">spoj</div>
|
||||
<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">
|
||||
<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>
|
||||
|
|
|
@ -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);}
|
Loading…
Reference in a new issue