Добавлена поддержка WebSocket

Этот коммит содержится в:
Глеб Иваницкий 2024-08-15 00:57:30 +03:00
родитель 98d90b1cf0
Коммит cce8c81e29
8 изменённых файлов: 291 добавлений и 327 удалений

Просмотреть файл

@ -17,6 +17,13 @@ server {
proxy_pass http://cit.csasq.ru; proxy_pass http://cit.csasq.ru;
} }
location /ws {
include /opt/cit-is-bot/nginx/secure-headers.conf;
include ./conf.d/ws-proxy.conf;
proxy_pass http://cit.csasq.ru;
}
location /static/ { location /static/ {
include /opt/cit-is-bot/nginx/secure-headers.conf; include /opt/cit-is-bot/nginx/secure-headers.conf;
include ./conf.d/gzip.conf; include ./conf.d/gzip.conf;

Просмотреть файл

@ -6,3 +6,4 @@ psycopg[binary]
pydantic pydantic
redis redis
uvicorn uvicorn
websockets

Просмотреть файл

@ -1,42 +1,97 @@
class Menu { // class Menu {
#application; // #application;
#root; // #root;
#items; // #items;
//
// constructor(application) {
// this.#application = application;
// this.#root = document.querySelector('#menu');
// this.#items = this.#root.querySelectorAll('input[data-page]');
// this.#items.forEach(item => {
// item.addEventListener('click', () => this.#application.open(item.dataset.page));
// });
// };
//
// setActive(pageId) {
// this.#items.forEach(item => item.classList.remove('active'));
// this.#items.forEach(item => item.dataset.page === pageId ? item.classList.add('active') : null);
// };
// }
//
// class Application {
// static DEFAULT_PAGE = 'dashboard';
//
// #inputContainer;
// #outputContainer;
// #menu;
// #pageId;
//
// constructor() {
// this.#inputContainer = document.querySelector('#input');
// this.#outputContainer = document.querySelector('#output');
// this.#menu = new Menu(this);
// };
//
// open(pageId) {
// this.#pageId = pageId;
// document.querySelectorAll('#input [data-page]').forEach(page => page.classList.remove('active'));
// document.querySelector(`#input [data-page="${pageId}"]`).classList.add('active');
// document.querySelectorAll('#output [data-page]').forEach(page => page.classList.remove('active'));
// this.#menu.setActive(pageId);
// };
// }
constructor(application) { // const mutableStateOf = {
this.#application = application; // _value: null,
this.#root = document.querySelector('#menu'); // constructor(value) {
this.#items = this.#root.querySelectorAll('input[data-page]'); // this._value = value
this.#items.forEach(item => { // },
item.addEventListener('click', () => this.#application.open(item.dataset.page)); // get value() {
}); // return this._value
}; // },
// set value(value) {
// this._value = value
// }
// }
const connectWebSocket = (url) => {
const ws = new WebSocket(url)
ws.onopen = () => {
setActive(pageId) {
this.#items.forEach(item => item.classList.remove('active'));
this.#items.forEach(item => item.dataset.page === pageId ? item.classList.add('active') : null);
};
} }
class Application { ws.onmessage = (event) => {
static DEFAULT_PAGE = 'dashboard'; const data = JSON.parse(event.data)
document.querySelectorAll(`#${data.id}`).forEach(element => element.toggleAttribute('selected', data.value))
#inputContainer;
#outputContainer;
#menu;
#pageId;
constructor() {
this.#inputContainer = document.querySelector('#input');
this.#outputContainer = document.querySelector('#output');
this.#menu = new Menu(this);
};
open(pageId) {
this.#pageId = pageId;
document.querySelectorAll('#input [data-page]').forEach(page => page.classList.remove('active'));
document.querySelector(`#input [data-page="${pageId}"]`).classList.add('active');
document.querySelectorAll('#output [data-page]').forEach(page => page.classList.remove('active'));
this.#menu.setActive(pageId);
};
} }
ws.onclose = () => {
}
return ws
}
const wsUrl = new URL(location)
wsUrl.protocol = 'wss'
wsUrl.pathname = '/ws'
const ws = connectWebSocket(wsUrl)
function getSwitch(id) {
const element = document.createElement('md-switch')
element.id = id
element.onclick = (event) => {
event.preventDefault()
const data = JSON.stringify({
id: id,
value: !event.currentTarget.hasAttribute('selected'),
})
ws.send(data)
}
return element
}
document.addEventListener('DOMContentLoaded', () => {
document.body.querySelector('main').append(getSwitch('qwerty'))
})

32
static/styles/dark-theme.css Обычный файл
Просмотреть файл

@ -0,0 +1,32 @@
:root {
--md-sys-color-primary: #D0BCFF;
--md-sys-color-on-primary: #381E72;
--md-sys-color-primary-container: #4F378B;
--md-sys-color-on-primary-container: #EADDFF;
--md-sys-color-secondary: #CCC2DC;
--md-sys-color-on-secondary: #332D41;
--md-sys-color-secondary-container: #4A4458;
--md-sys-color-on-secondary-container: #E8DEF8;
--md-sys-color-tertiary: #EFB8C8;
--md-sys-color-on-tertiary: #492532;
--md-sys-color-tertiary-container: #633B48;
--md-sys-color-on-tertiary-container: #FFD8E4;
--md-sys-color-error: #F2B8B5;
--md-sys-color-on-error: #601410;
--md-sys-color-error-container: #8C1D18;
--md-sys-color-on-error-container: #F9DEDC;
--md-sys-color-surface: #141218;
--md-sys-color-on-surface: #E6E0E9;
--md-sys-color-surface-variant: #49454F;
--md-sys-color-on-surface-variant: #CAC4D0;
--md-sys-color-surface-container-highest: #36343B;
--md-sys-color-surface-container-high: #2B2930;
--md-sys-color-surface-container: #211F26;
--md-sys-color-surface-container-low: #1D1B20;
--md-sys-color-surface-container-lowest: #0F0D13;
--md-sys-color-inverse-surface: #E6E0E9;
--md-sys-color-inverse-on-surface: #322F35;
--md-sys-color-surface-tint: #D0BCFF;
--md-sys-color-outline: #938F99;
--md-sys-color-outline-variant: #49454F;
}

32
static/styles/light-theme.css Обычный файл
Просмотреть файл

@ -0,0 +1,32 @@
:root {
--md-sys-color-primary: #6750A4;
--md-sys-color-on-primary: #FFFFFF;
--md-sys-color-primary-container: #EADDFF;
--md-sys-color-on-primary-container: #4F378B;
--md-sys-color-secondary: #625B71;
--md-sys-color-on-secondary: #FFFFFF;
--md-sys-color-secondary-container: #E8DEF8;
--md-sys-color-on-secondary-container: #4A4458;
--md-sys-color-tertiary: #7D5260;
--md-sys-color-on-tertiary: #FFFFFF;
--md-sys-color-tertiary-container: #FFD8E4;
--md-sys-color-on-tertiary-container: #633B48;
--md-sys-color-error: #B3261E;
--md-sys-color-on-error: #FFFFFF;
--md-sys-color-error-container: #F9DEDC;
--md-sys-color-on-error-container: #8C1D18;
--md-sys-color-surface: #FEF7FF;
--md-sys-color-on-surface: #1D1B20;
--md-sys-color-surface-variant: #E7E0EC;
--md-sys-color-on-surface-variant: #49454F;
--md-sys-color-surface-container-highest: #E6E0E9;
--md-sys-color-surface-container-high: #ECE6F0;
--md-sys-color-surface-container: #F3EDF7;
--md-sys-color-surface-container-low: #F7F2FA;
--md-sys-color-surface-container-lowest: #FFFFFF;
--md-sys-color-inverse-surface: #322F35;
--md-sys-color-inverse-on-surface: #F5EFF7;
--md-sys-color-surface-tint: #6750A4;
--md-sys-color-outline: #79747E;
--md-sys-color-outline-variant: #CAC4D0;
}

Просмотреть файл

@ -1,307 +1,48 @@
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('https://cdn.csasq.ru/common/fonts/exo-2/cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('https://cdn.csasq.ru/common/fonts/exo-2/cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('https://cdn.csasq.ru/common/fonts/exo-2/vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('https://cdn.csasq.ru/common/fonts/exo-2/latin-ext.woff2') format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('https://cdn.csasq.ru/common/fonts/exo-2/latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--color-1: #2E436E;
--color-2: #1E1F22;
--color-3: #2B2D30;
--color-4: #43454A;
--color-5: #575757;
--color-6: #CED0D6;
--color-7: #569ACC;
--color-8: #3574F0;
--color-9: #528D58;
--color-10: #C94F4F;
}
html,
input,
button,
textarea,
select {
font-family: 'Exo 2', sans-serif;
}
html,
body {
height: 100%;
color: var(--color-6);
background-color: var(--color-2);
color-scheme: dark light;
}
body { body {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0;
}
input[type="text"],
input[type="number"] {
color: var(--color-6);
}
a {
color: var(--color-7);
text-decoration: none;
outline: none;
}
a:hover,
a:focus {
text-decoration: underline;
}
.hidden {
display: none;
}
#top-bar {
display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; margin: 0;
padding: .5rem; background-color: var(--md-sys-color-surface-container-high);
background-color: var(--color-3); scroll-behavior: smooth;
border-bottom: solid 2px var(--color-2);
} }
#logo { main {
display: flex; padding: 1rem;
align-items: center;
} }
#icon { main > * {
width: 1.5rem; width: 100%;
height: 1.5rem;
} }
#title { nav {
margin-left: .5rem;
font-size: 1.2rem;
font-weight: bold;
}
#actions {
display: flex;
align-items: center;
}
#actions button {
display: flex;
justify-content: center;
align-items: center;
padding: .4rem .6rem;
color: var(--color-6);
background: none;
border: none;
border-radius: .25rem;
outline: none;
font-size: 1rem;
cursor: pointer;
}
#actions button:hover,
#actions button:focus {
background: var(--color-4);
}
#actions .icon {
width: 1.2rem;
height: 1.2rem;
}
#content {
position: sticky;
display: flex;
height: 100%;
overflow-y: auto;
}
#menu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: stretch; background-color: var(--md-sys-color-surface-container);
min-width: 12rem;
max-width: 16rem;
background-color: var(--color-3);
} }
#menu .header { nav ul {
padding: .75rem;
font-weight: bold;
}
#menu .item {
display: flex; display: flex;
align-items: stretch;
}
#menu input[type="button"] {
padding: .5rem .75rem;
width: 100%;
color: var(--color-6);
background: none;
border: none;
outline: none;
white-space: pre-wrap;
text-align: start;
cursor: pointer;
}
#menu input[type="button"]:hover,
#menu input[type="button"]:focus {
color: white;
}
#menu input[type="button"].active {
color: white;
}
#menu .marker {
width: 3px;
background: none;
border-radius: 3px;
}
#menu input[type="button"].active + .marker {
background: var(--color-8);
}
#main {
position: sticky;
display: flex;
width: 100%;
overflow: auto;
}
#input {
flex: 1 1 auto;
padding: .75rem;
min-width: 24rem;
overflow: auto;
}
#input [data-page]:not(.active),
#output [data-page]:not(.active) {
display: none;
}
#main input {
width: 10rem;
background: none;
border: none;
outline: solid 1px var(--color-5);
}
#main input:focus {
outline: solid 2px var(--color-8);
}
#main input[type="button"] {
padding: .25rem 1.5rem;
width: 100%;
color: white;
border-radius: .25rem;
outline: none;
cursor: pointer;
}
#main input[type="button"].primary {
padding: .25rem 1.5rem;
background: var(--color-8);
border: solid 1px var(--color-2);
}
#main input[type="button"].secondary {
padding: .25rem 1.5rem;
background: none;
border: solid 1px var(--color-4);
}
#main input[type="button"]:focus {
outline: solid 2px var(--color-8);
}
#output {
flex: 1 1 auto;
padding: .75rem;
border-left: solid 1px var(--color-3);
overflow: auto;
}
#bottom-bar {
display: flex;
justify-content: end;
align-items: center; align-items: center;
gap: 1rem;
margin: 0;
padding: .5rem; padding: .5rem;
background-color: var(--color-3); list-style: none;
border-top: solid .15rem var(--color-2);
font-size: .8rem;
} }
table.properties th { nav li {
font-weight: normal;
text-align: start;
}
table.properties th[colspan="2"] {
padding: 1rem 0 .5rem 0;
font-weight: bold;
}
table.properties td:nth-child(2) {
padding-left: 1rem;
}
table.properties td[colspan="2"] {
padding: .5rem 0 .5rem 0;
}
table.properties td[colspan="2"] > div {
display: flex; display: flex;
flex-direction: column;
width: 100%;
color: var(--md-sys-color-on-surface);
text-align: center;
} }
table.properties td[colspan="2"] > div > :not(:first-child) { nav md-icon-button {
margin-left: .5rem; width: 100%;
}
table.matrix input {
width: 3rem !important;
} }

Просмотреть файл

@ -8,6 +8,8 @@
<title>Настройки</title> <title>Настройки</title>
<link rel="icon" href="/static/icons/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/static/icons/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" type="text/css" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" type="text/css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined" type="text/css" />
<link rel="stylesheet" href="/static/styles/main.css" type="text/css" />
<script type="importmap"> <script type="importmap">
{ {
"imports": { "imports": {
@ -21,14 +23,57 @@
document.adoptedStyleSheets.push(typescaleStyles.styleSheet); document.adoptedStyleSheets.push(typescaleStyles.styleSheet);
</script> </script>
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js" type="text/javascript"></script>
<style> <script src="/static/scripts/vendor.js" type="text/javascript"></script>
body > * { <script>
width: 100%; document.addEventListener('DOMContentLoaded', async () => {
} const link = document.createElement('link')
</style> const href = new URL(location)
href.pathname = `/static/styles/${window.Telegram.WebApp.colorScheme}-theme.css`
link.rel = 'stylesheet'
link.href = href.toString()
link.type = 'text/css'
document.head.append(link)
window.Telegram.WebApp.ready()
})
</script>
</head> </head>
<body> <body>
<main>
<md-outlined-text-field label="Favorite color"></md-outlined-text-field> <md-outlined-text-field label="Favorite color"></md-outlined-text-field>
<md-switch id="qwerty"></md-switch>
<md-switch id="qwerty"></md-switch>
<md-switch id="qwerty"></md-switch>
<md-switch id="qwerty"></md-switch>
</main>
<nav class="md-typescale-body-small">
<ul>
<li>
<md-icon-button href="/">
<md-icon>dashboard</md-icon>
</md-icon-button>
Дашборд
</li>
<li>
<md-icon-button href="/">
<md-icon>ballot</md-icon>
</md-icon-button>
Опросы
</li>
<li>
<md-icon-button href="/">
<md-icon>chat</md-icon>
</md-icon-button>
Чаты
</li>
<li>
<md-icon-button href="/">
<md-icon>group</md-icon>
</md-icon-button>
Пользователи
</li>
</ul>
</nav>
</body> </body>
</html> </html>

Просмотреть файл

@ -1,12 +1,49 @@
import asyncio
import os import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.websockets import WebSocket, WebSocketDisconnect
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
import config import config
class ConnectionManager:
connections: list[WebSocket]
class StateError(Exception):
pass
def __init__(
self,
):
self.connections = []
async def connect(
self,
websocket: WebSocket,
):
await websocket.accept()
self.connections.append(websocket)
def disconnect(
self,
websocket: WebSocket,
):
self.connections.remove(websocket)
async def broadcast(
self,
data: dict,
):
for connection in self.connections:
asyncio.ensure_future(connection.send_json(data))
connection_manager = ConnectionManager()
app = FastAPI( app = FastAPI(
title=config.Main.app_name, title=config.Main.app_name,
) )
@ -33,3 +70,17 @@ async def _():
title='Дашборд', title='Дашборд',
), ),
) )
@app.websocket(
path='/ws',
)
async def _(
websocket: WebSocket,
):
await connection_manager.connect(websocket)
try:
while True:
await connection_manager.broadcast(await websocket.receive_json())
except WebSocketDisconnect:
connection_manager.disconnect(websocket)