add api spec, add fetching of headset details, improve website flow
|
|
@ -1,7 +1,8 @@
|
||||||
|
from flask.helpers import send_from_directory
|
||||||
from velconnect.auth import require_api_key
|
from velconnect.auth import require_api_key
|
||||||
from velconnect.db import connectToDB
|
from velconnect.db import connectToDB
|
||||||
from velconnect.logger import logger
|
from velconnect.logger import logger
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify, render_template, url_for
|
||||||
import time
|
import time
|
||||||
import simplejson as json
|
import simplejson as json
|
||||||
from random import random
|
from random import random
|
||||||
|
|
@ -9,6 +10,20 @@ from random import random
|
||||||
bp = Blueprint('api', __name__)
|
bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/', methods=['GET'])
|
||||||
|
@require_api_key(0)
|
||||||
|
def api_home():
|
||||||
|
return render_template('api_spec.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api_spec.json', methods=['GET'])
|
||||||
|
@require_api_key(0)
|
||||||
|
def api_spec():
|
||||||
|
return send_from_directory('static', 'api_spec.json')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/get_all_headsets', methods=['GET'])
|
@bp.route('/get_all_headsets', methods=['GET'])
|
||||||
@require_api_key(0)
|
@require_api_key(0)
|
||||||
def get_all_headsets():
|
def get_all_headsets():
|
||||||
|
|
@ -48,8 +63,6 @@ def update_paring_code():
|
||||||
return 'Must supply hw_id', 400
|
return 'Must supply hw_id', 400
|
||||||
if 'pairing_code' not in data:
|
if 'pairing_code' not in data:
|
||||||
return 'Must supply pairing_code', 400
|
return 'Must supply pairing_code', 400
|
||||||
if 'pairing_code' not in data:
|
|
||||||
return 'Must supply pairing_code', 400
|
|
||||||
|
|
||||||
conn, curr = connectToDB()
|
conn, curr = connectToDB()
|
||||||
|
|
||||||
|
|
@ -73,6 +86,24 @@ def update_paring_code():
|
||||||
|
|
||||||
return 'Success'
|
return 'Success'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/get_headset_details/<hw_id>', methods=['GET'])
|
||||||
|
@require_api_key(10)
|
||||||
|
def get_headset_details(hw_id):
|
||||||
|
return jsonify(get_headset_details_db(hw_id))
|
||||||
|
|
||||||
|
|
||||||
|
def get_headset_details_db(hw_id):
|
||||||
|
conn, curr = connectToDB()
|
||||||
|
query = """
|
||||||
|
SELECT * FROM `Headset` WHERE hw_id=%(hw_id)s;
|
||||||
|
"""
|
||||||
|
curr.execute(query, {'hw_id': hw_id})
|
||||||
|
values = [dict(row) for row in curr.fetchall()]
|
||||||
|
curr.close()
|
||||||
|
return jsonify(values)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/get_room_details/<room_id>', methods=['GET'])
|
@bp.route('/get_room_details/<room_id>', methods=['GET'])
|
||||||
@require_api_key(10)
|
@require_api_key(10)
|
||||||
|
|
@ -91,13 +122,13 @@ def get_room_details_db(room_id):
|
||||||
return jsonify(values)
|
return jsonify(values)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/create_room', methods=['GET'])
|
@bp.route('/update_room/<room_id>', methods=['POST'])
|
||||||
@require_api_key(10)
|
@require_api_key(10)
|
||||||
def create_room():
|
def update_room(room_id):
|
||||||
return jsonify(create_room_db())
|
return jsonify(update_room_db(room_id, request.json))
|
||||||
|
|
||||||
|
|
||||||
def create_room_db():
|
def update_room_db(room_id, data):
|
||||||
room_id = random.randint(0, 9999)
|
room_id = random.randint(0, 9999)
|
||||||
conn, curr = connectToDB()
|
conn, curr = connectToDB()
|
||||||
query = """
|
query = """
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {
|
||||||
|
"version": "1.0",
|
||||||
|
"title": "VEL Connect API",
|
||||||
|
"description": "This API provides an interface with the database for pairing and controlling headsets while within a VEL app."
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "http://3.23.79.32/api/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Write"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/get_all_headsets": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Get all headset data",
|
||||||
|
"description" : "Gets a list of all headset data that has been stored",
|
||||||
|
"tags": ["Read"],
|
||||||
|
"parameters": [
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/pair_headset/{pairing_code}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Pair a headset by code",
|
||||||
|
"description" : "Tries to pair to a headset with a supplied pairing code. Returns the hw_id if successfully paired. Used by the website for pairing.",
|
||||||
|
"tags": ["Write"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "pairing_code",
|
||||||
|
"example": "1234",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Pairing Code",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/update_pairing_code": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Update Pairing Code",
|
||||||
|
"description": "This is called by the VR application on login or when the pairing code is reset. If this is the first time this headset is seen, it also creates this headset in the database. A JSON body must be submitted with hw_id and pairing_code defined.",
|
||||||
|
"tags": ["Write"],
|
||||||
|
"parameters": [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/get_headset_details/{hw_id}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Get Headset Details",
|
||||||
|
"description" : "Gets the info associated with a specific hardware id.",
|
||||||
|
"tags": ["Read"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "hw_id",
|
||||||
|
"example": "123456789",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Hardware id of the device",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/get_room_details/{room_id}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Get Room Details",
|
||||||
|
"description" : "Gets the info associated with a specific room id. Used by the website to show initial data, and polled often by the headset.",
|
||||||
|
"tags": ["Read"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "room_id",
|
||||||
|
"example": "1234",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Room id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/update_room/{room_id}": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Update Room Details",
|
||||||
|
"description" : "Sets the info associated with a specific room id. The room id is specified in the url and the data is specified in the JSON body. Used by both the website and headset.",
|
||||||
|
"tags": ["Write"],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "room_id",
|
||||||
|
"example": "1234",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Room id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
},
|
||||||
|
"securitySchemes": {
|
||||||
|
"api_key": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "x-api-key",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BasicAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/mstile-150x150.png"/>
|
||||||
|
<TileColor>#b91d47</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
|
After Width: | Height: | Size: 815 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="300.000000pt" height="300.000000pt" viewBox="0 0 300.000000 300.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(0.000000,300.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M605 1500 l600 -600 97 0 98 0 0 600 0 600 -100 0 -100 0 0 -452 0
|
||||||
|
-453 -453 453 -452 452 -145 0 -145 0 600 -600z"/>
|
||||||
|
<path d="M1500 2000 l0 -100 300 0 300 0 0 100 0 100 -300 0 -300 0 0 -100z"/>
|
||||||
|
<path d="M2200 1500 l0 -600 398 2 397 3 3 98 3 97 -301 0 -300 0 -2 498 -3
|
||||||
|
497 -97 3 -98 3 0 -601z"/>
|
||||||
|
<path d="M1500 1500 l0 -100 300 0 300 0 0 100 0 100 -300 0 -300 0 0 -100z"/>
|
||||||
|
<path d="M1500 1000 l0 -100 300 0 300 0 0 100 0 100 -300 0 -300 0 0 -100z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 977 B |
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"short_name": "",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
|
||||||
|
function httpGetAsync(theUrl, callback, failCallback) {
|
||||||
|
var xmlHttp = new XMLHttpRequest();
|
||||||
|
xmlHttp.onreadystatechange = function () {
|
||||||
|
if (xmlHttp.readyState == 4) {
|
||||||
|
if (xmlHttp.status == 200) {
|
||||||
|
callback(xmlHttp.responseText);
|
||||||
|
} else {
|
||||||
|
failCallback(xmlHttp.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xmlHttp.open("GET", theUrl, true); // true for asynchronous
|
||||||
|
xmlHttp.send(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(cname) {
|
||||||
|
let name = cname + "=";
|
||||||
|
let decodedCookie = decodeURIComponent(document.cookie);
|
||||||
|
let ca = decodedCookie.split(';');
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i];
|
||||||
|
while (c.charAt(0) == ' ') {
|
||||||
|
c = c.substring(1);
|
||||||
|
}
|
||||||
|
if (c.indexOf(name) == 0) {
|
||||||
|
return c.substring(name.length, c.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"> <!-- Important: rapi-doc uses utf8 charecters -->
|
||||||
|
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<rapi-doc
|
||||||
|
render-style = "read"
|
||||||
|
primary-color = "#bc1f2d"
|
||||||
|
show-header = "false"
|
||||||
|
show-info = "true"
|
||||||
|
spec-url = "/api/api_spec.json"
|
||||||
|
default-schema-tab = 'example'
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
<div slot="nav-logo" style="display: flex; align-items: center; justify-content: center;">
|
||||||
|
<img src = "/static/favicons/android-chrome-256x256.png" style="width:10em; margin: auto;" />
|
||||||
|
</div>
|
||||||
|
</rapi-doc>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicons/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/static/favicons/site.webmanifest">
|
||||||
|
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5">
|
||||||
|
<meta name="msapplication-TileColor" content="#b91d47">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,85 @@
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicons/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/static/favicons/site.webmanifest">
|
||||||
|
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5">
|
||||||
|
<meta name="msapplication-TileColor" content="#b91d47">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/static/css/spectre.min.css">
|
||||||
|
<script src="/static/js/util.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
.container {
|
||||||
background-color: #333;
|
max-width: 30em;
|
||||||
font-size: 1.2em;
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: 1em;
|
||||||
|
box-shadow: 0 0 2em #0003;
|
||||||
}
|
}
|
||||||
|
|
||||||
.centered {
|
.centered {
|
||||||
margin: 8em auto;
|
margin: auto;
|
||||||
width: max-content;
|
|
||||||
font-family: arial, sans-serif;
|
|
||||||
color: #ddd;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="centered">
|
<div class="container">
|
||||||
Headset pairing. Wow so cool! 😋😎
|
|
||||||
|
<div id="loading" class="loading loading-lg"></div>
|
||||||
|
<div id="failure">☹️</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="headset_details" style="display: none;">
|
||||||
|
<h2>HW ID</h2>
|
||||||
|
<p id="hw_id"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
let submit_button = document.getElementById('submit_pairing_code');
|
||||||
|
let pair_code_input = document.getElementById('pair_code');
|
||||||
|
let loading = document.getElementById('loading');
|
||||||
|
let enter_pairing_id = document.getElementById('enter_pairing_id');
|
||||||
|
let headset_details = document.getElementById('headset_details');
|
||||||
|
let hw_id_field = document.getElementById('hw_id');
|
||||||
|
let failure = document.getElementById('failure');
|
||||||
|
|
||||||
|
|
||||||
|
// check cookie
|
||||||
|
let hw_id = getCookie('hw_id');
|
||||||
|
loading.style.display = "none";
|
||||||
|
if (hw_id != "") {
|
||||||
|
|
||||||
|
httpGetAsync('/api/get_headset_details/' + hw_id, (resp) => {
|
||||||
|
console.log(resp);
|
||||||
|
let respData = JSON.parse(resp);
|
||||||
|
if (respData['hw_id'] != '') {
|
||||||
|
hw_id_field.innerText = respData['hw_id'];
|
||||||
|
}
|
||||||
|
headset_details.style.display = "block";
|
||||||
|
}, (status) => {
|
||||||
|
failure.style.display = "block";
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
window.location.href = "/pair";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicons/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/static/favicons/site.webmanifest">
|
||||||
|
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5">
|
||||||
|
<meta name="msapplication-TileColor" content="#b91d47">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/static/css/spectre.min.css">
|
<link rel="stylesheet" href="/static/css/spectre.min.css">
|
||||||
|
<script src="/static/js/util.js"></script>
|
||||||
<style>
|
<style>
|
||||||
#pair_code {
|
#pair_code {
|
||||||
max-width: 4em;
|
max-width: 4em;
|
||||||
|
|
@ -49,19 +61,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function httpGetAsync(theUrl, callback, failCallback) {
|
|
||||||
var xmlHttp = new XMLHttpRequest();
|
|
||||||
xmlHttp.onreadystatechange = function () {
|
|
||||||
if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
|
|
||||||
callback(xmlHttp.responseText);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
failCallback(xmlHttp.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xmlHttp.open("GET", theUrl, true); // true for asynchronous
|
|
||||||
xmlHttp.send(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let submit_button = document.getElementById('submit_pairing_code');
|
let submit_button = document.getElementById('submit_pairing_code');
|
||||||
let pair_code_input = document.getElementById('pair_code');
|
let pair_code_input = document.getElementById('pair_code');
|
||||||
|
|
@ -70,7 +69,7 @@
|
||||||
console.log(resp);
|
console.log(resp);
|
||||||
let respData = JSON.parse(resp);
|
let respData = JSON.parse(resp);
|
||||||
if (respData['hw_id'] != '') {
|
if (respData['hw_id'] != '') {
|
||||||
document.cookie = "hw_id="+respData['hw_id'];
|
document.cookie = "hw_id=" + respData['hw_id'] + "; SameSite=None; Secure";
|
||||||
window.location.href = "/success";
|
window.location.href = "/success";
|
||||||
}
|
}
|
||||||
}, (status) => {
|
}, (status) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicons/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/static/favicons/site.webmanifest">
|
||||||
|
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5">
|
||||||
|
<meta name="msapplication-TileColor" content="#b91d47">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
|
|
|
||||||