Add current code
This commit is contained in:
parent
093f44edf5
commit
4b80b7353b
5 changed files with 591 additions and 0 deletions
315
server.py
Executable file
315
server.py
Executable file
|
@ -0,0 +1,315 @@
|
|||
#!/bin/env python3
|
||||
# This code is licensed under GNU GPLv3.
|
||||
import fastapi
|
||||
import os
|
||||
import time
|
||||
import httpx
|
||||
import typing
|
||||
import hashlib
|
||||
import pydantic
|
||||
from pathlib import Path
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import Response, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
class AppIDList(pydantic.BaseModel):
|
||||
app_ids: list[int] | int
|
||||
|
||||
|
||||
app = fastapi.FastAPI(
|
||||
title="How much does The Sims 4 cost?",
|
||||
description="A simple API to get the price of The Sims 4 (and technically other games) on Steam. Source @ /server.py",
|
||||
license_info={
|
||||
"name": "GNU AGPLv3",
|
||||
"identifier": "AGPL-3.0"
|
||||
},
|
||||
contact={
|
||||
"name": "[Matrix] @nex",
|
||||
"url": "https://matrix.to/#/@nex:nexy7574.co.uk"
|
||||
},
|
||||
version="1.2.0",
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "HEAD", "POST"],
|
||||
allow_headers=["*"]
|
||||
)
|
||||
app.state.session = httpx.AsyncClient(http2=False)
|
||||
app.state.cache = {}
|
||||
app.state.ratelimit = {}
|
||||
app.state.etags = {}
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/appinfo",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Successfully fetched all of the app info IDs.",
|
||||
"headers": {
|
||||
"X-Ratelimit-Count": {
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The number of requests you have sent to the API in the last 30 seconds."
|
||||
},
|
||||
"X-Ratelimit-Remaining": {
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The number of requests you have left in the next 30 seconds."
|
||||
},
|
||||
"X-Ratelimit-Reset": {
|
||||
"schema": {
|
||||
"type": "float"
|
||||
},
|
||||
"description": "The unix timestamp of when the ratelimit resets."
|
||||
},
|
||||
}
|
||||
},
|
||||
304: {
|
||||
"description": "The ETag provided was valid and no data was returned.",
|
||||
},
|
||||
429: {
|
||||
"description": "You hit the anti-abuse ratelimit. Check the `Retry-After` header for more information.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"schema": {
|
||||
"type": "float"
|
||||
},
|
||||
"description": "The amount of seconds you should wait before retrying."
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "There was an upstream error."
|
||||
}
|
||||
}
|
||||
)
|
||||
async def appinfo(
|
||||
req: fastapi.Request,
|
||||
body: AppIDList,
|
||||
cc: str = fastapi.Query(None),
|
||||
user_agent: str = fastapi.Header(..., alias="User-Agent"),
|
||||
if_none_match: str = fastapi.Header(None, alias="If-None-Match"),
|
||||
):
|
||||
"""
|
||||
This API endpoint proxies the call to the steam web API, and returns the data in a more usable format.
|
||||
This also gets around the CORS issue that prevents this being a serverless solution.
|
||||
|
||||
This endpoint:
|
||||
- Caches the data internally for 24 hours, regardless of etag and cache control.
|
||||
- Has a ratelimit of 10 requests per 20 seconds, shared across all endpoints.
|
||||
"""
|
||||
if req.client.host in app.state.ratelimit:
|
||||
expires = app.state.ratelimit[req.client.host]["expires"]
|
||||
count = app.state.ratelimit[req.client.host]["count"]
|
||||
rl_remaining = 10 - count
|
||||
now = time.time()
|
||||
if expires >= now:
|
||||
if count >= 10:
|
||||
app.state.ratelimit[req.client.host]["expires"] += (2 * (1.125 ** count))
|
||||
return Response(
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(app.state.ratelimit[req.client.host]["expires"] - now)}
|
||||
)
|
||||
else:
|
||||
app.state.ratelimit[req.client.host]["count"] += 1
|
||||
else:
|
||||
del app.state.ratelimit[req.client.host]
|
||||
else:
|
||||
expires = time.time() + 20
|
||||
app.state.ratelimit[req.client.host] = {
|
||||
"expires": expires,
|
||||
"count": 1
|
||||
}
|
||||
rl_remaining = 9
|
||||
count = 1
|
||||
|
||||
auto_cc = "N/A"
|
||||
if if_none_match:
|
||||
if_none_match = if_none_match.replace("W/", "").strip('"')
|
||||
if not cc:
|
||||
_ccr = await app.state.session.get(f"https://ipinfo.io/{req.client.host}/country")
|
||||
cc = _ccr.text.strip().lower()
|
||||
auto_cc = cc
|
||||
app_ids = body.app_ids
|
||||
if isinstance(app_ids, int):
|
||||
app_ids = [app_ids]
|
||||
|
||||
multi_fetch = len(app_ids) > 1
|
||||
cache_hit = "MISS"
|
||||
|
||||
if if_none_match in app.state.etags:
|
||||
if app.state.etags[if_none_match] > time.time():
|
||||
cache_hit = "FULL-HIT"
|
||||
return Response(status_code=304)
|
||||
else:
|
||||
del app.state.etags[if_none_match]
|
||||
|
||||
_md5 = hashlib.md5((",".join(map(str, app_ids)) + cc).encode(), usedforsecurity=False).hexdigest()
|
||||
|
||||
values = {}
|
||||
remaining = app_ids.copy()
|
||||
for app_id in app_ids:
|
||||
if int(app_id) in app.state.cache:
|
||||
data, expires = app.state.cache[app_id]
|
||||
if expires > time.time():
|
||||
cache_hit = "PARTIAL-HIT"
|
||||
values[app_id] = data
|
||||
remaining.pop(remaining.index(app_id))
|
||||
else:
|
||||
del app.state.cache[app_id]
|
||||
values[app_id] = None
|
||||
else:
|
||||
values[app_id] = None
|
||||
|
||||
if len(remaining) == 0:
|
||||
return JSONResponse(
|
||||
values,
|
||||
headers={
|
||||
"cache-control": "public, max-age=806400",
|
||||
"X-Cache": "FULL-HIT",
|
||||
"X-Ratelimit-Remaining": str(rl_remaining),
|
||||
"X-Ratelimit-Count": str(count),
|
||||
"X-Ratelimit-Reset": str(expires),
|
||||
"X-Ratelimit-Reset-After": str(expires),
|
||||
},
|
||||
)
|
||||
elif len(remaining) == len(app_ids):
|
||||
cache_hit = "MISS"
|
||||
|
||||
params = {
|
||||
"appids": ",".join(map(str, remaining))
|
||||
}
|
||||
if cc is not None:
|
||||
params["cc"] = cc
|
||||
|
||||
if multi_fetch:
|
||||
params["filters"] = "price_overview"
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent,
|
||||
}
|
||||
|
||||
result = original_response = await app.state.session.get(
|
||||
"https://store.steampowered.com/api/appdetails",
|
||||
params=params,
|
||||
headers=headers,
|
||||
)
|
||||
if result.status_code == 429:
|
||||
return Response(status_code=429)
|
||||
elif result.status_code not in [304, 200]:
|
||||
raise fastapi.HTTPException(result.status_code, result.text)
|
||||
else:
|
||||
app.state.etag = result.headers.get("etag")
|
||||
app.state.last_modified = result.headers.get("last-modified")
|
||||
|
||||
result = result.json()
|
||||
for _app_id, data in result.items():
|
||||
if "data" in data:
|
||||
app.state.cache[int(_app_id)] = (data["data"], time.time() + 86400)
|
||||
values[int(_app_id)] = data["data"]
|
||||
|
||||
app.state.etags[_md5] = time.time() + 86400
|
||||
return JSONResponse(
|
||||
values,
|
||||
original_response.status_code,
|
||||
headers={
|
||||
"ETag": _md5,
|
||||
"cache-control": "public, max-age=806400",
|
||||
"X-Cache": str(cache_hit),
|
||||
"X-IP": str(req.client.host),
|
||||
"X-Ratelimit-Remaining": str(rl_remaining),
|
||||
"X-Ratelimit-Count": str(count),
|
||||
"X-Ratelimit-Reset": str(expires),
|
||||
"X-Ratelimit-Reset-After": str(expires),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
RL_HEADERS = {
|
||||
"X-Ratelimit-Throttled": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": "Whether or not you are actively being throttled."
|
||||
},
|
||||
"X-Ratelimit-Count": {
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The number of requests you have sent to the API in the last 30 seconds."
|
||||
},
|
||||
"X-Ratelimit-Remaining": {
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The number of requests you have left in the next 30 seconds."
|
||||
},
|
||||
"X-Ratelimit-Reset": {
|
||||
"schema": {
|
||||
"type": "float"
|
||||
},
|
||||
"description": "The unix timestamp of when the ratelimit resets."
|
||||
}
|
||||
}
|
||||
RL_DOC = {
|
||||
204: {
|
||||
"description": "Successfully checked the ratelimit.",
|
||||
"headers": RL_HEADERS
|
||||
},
|
||||
429: {
|
||||
"description": "You hit the anti-abuse ratelimit. Check the `Retry-After` header for more information.",
|
||||
"headers": {
|
||||
**RL_HEADERS,
|
||||
"Retry-After": {
|
||||
"schema": {
|
||||
"type": "float"
|
||||
},
|
||||
"description": "The amount of seconds you should wait before retrying."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/api/ratelimit", status_code=204, responses=RL_DOC)
|
||||
@app.head("/api/ratelimit", status_code=204, responses=RL_DOC)
|
||||
async def check_ratelimit(req: fastapi.Request):
|
||||
"""
|
||||
Checks the current ratelimit. See GET /api/appinfo for more info.
|
||||
"""
|
||||
headers = {
|
||||
"X-Ratelimit-Throttled": "False",
|
||||
"X-Ratelimit-Remaining": "10",
|
||||
"X-Ratelimit-Count": "0",
|
||||
"X-Ratelimit-Reset": "0",
|
||||
}
|
||||
if req.client.host in app.state.ratelimit:
|
||||
expires = app.state.ratelimit[req.client.host]["expires"]
|
||||
count = app.state.ratelimit[req.client.host]["count"]
|
||||
rl_remaining = 10 - count
|
||||
|
||||
headers["X-Ratelimit-Remaining"] = str(rl_remaining)
|
||||
headers["X-Ratelimit-Count"] = str(count)
|
||||
headers["X-Ratelimit-Reset"] = str(expires)
|
||||
headers["X-Ratelimit-Throttled"] = str(rl_remaining >= 10 and expires >= time.time())
|
||||
if headers["X-Ratelimit-Throttled"] == "True":
|
||||
headers["Retry-After"] = str(expires - time.time())
|
||||
return Response(None, 204 if headers["X-Ratelimit-Throttled"] == "False" else 429, headers=headers)
|
||||
|
||||
|
||||
|
||||
app.mount(
|
||||
"/",
|
||||
StaticFiles(directory=Path.cwd() / "static", html=True, check_dir=True, follow_symlink=True),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
import os
|
||||
print("Starting in", Path.cwd())
|
||||
uvicorn.run(app, host="0.0.0.0", port=1037, forwarded_allow_ips="*")
|
BIN
static/favicon.ico
Executable file
BIN
static/favicon.ico
Executable file
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
35
static/index.html
Executable file
35
static/index.html
Executable file
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>How much does the entire The Sims 4 game cost right now?</title>
|
||||
<meta name="description" content="See how much the entirety of the game 'The Sims 4' costs right now if you were to purchase it with all DLCs!">
|
||||
<!-- OpenGraph tags -->
|
||||
<meta property="og:title" content="How much does the entire The Sims 4 game cost right now?">
|
||||
<meta property="og:description" content="See how much the entirety of the game 'The Sims 4' costs right now if you were to purchase it with all DLCs!">
|
||||
<link rel="stylesheet" href="/style.css"/>
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
|
||||
</head>
|
||||
<body style="text-align: center">
|
||||
<div id="retrying" hidden>
|
||||
<div>
|
||||
<h1>There was an issue loading the data.</h1>
|
||||
<p>This page will reload in <span id="retry-after">0.0</span> seconds to try again.</p>
|
||||
<p><i>Error: Ratelimited (HTTP_429)</i></p>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<h1>Currently <span id="name">The Sims 4</span> costs<span id="dlcs" hidden> (with all <span id="dlc-count"></span>DLCs)</span></h1>
|
||||
<h2 id="price">£1000.00</h2>
|
||||
|
||||
<div hidden id="comparison">
|
||||
<p>That's equivalent to...</p>
|
||||
<p><i>Note: Prices may not be accurate. See script.js for more details.</i></p>
|
||||
<ul></ul>
|
||||
</div>
|
||||
</main>
|
||||
<noscript><p>(You need to enable JavaScript to load this)</p></noscript>
|
||||
<script src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
179
static/script.js
Executable file
179
static/script.js
Executable file
|
@ -0,0 +1,179 @@
|
|||
// This code is licensed under GNU GPLv3.
|
||||
const BASE_URL = "/api/appinfo";
|
||||
var TS4_APP_ID = 1222670;
|
||||
const APPID_REGEX = /#(\d{5,10})/
|
||||
if(APPID_REGEX.test(window.location.hash)===true) {
|
||||
let matches = APPID_REGEX.exec(window.location.hash);
|
||||
TS4_APP_ID = parseInt(matches[1])
|
||||
console.debug("Set app ID to " + TS4_APP_ID);
|
||||
} else {
|
||||
console.debug("No custom ID set or does not match regex.");
|
||||
}
|
||||
const PRICE_ELEMENT = document.getElementById("price");
|
||||
const NAME_ELEMENT = document.getElementById("name");
|
||||
|
||||
/*
|
||||
* These prices are gathered from a range of sources, such as Tesco, Amazon, and CeX.
|
||||
* Some prices may no longer be correct. They do not include discounts.
|
||||
*
|
||||
* Last updated: 10/12/2023
|
||||
*/
|
||||
const PRICES = {
|
||||
"Freddo (18g)": 0.25,
|
||||
"Skittles (136G)": 1.25,
|
||||
"Cadbury Fingers (114G)": 1.70,
|
||||
"howmuchdoesthesims4cost.lol": 1.78,
|
||||
"Pepsi Max (2L)": 2.00,
|
||||
"Pringles (Salt & Vinegar, 185g)": 2.25,
|
||||
"BLÅHAJ Soft toy, shark (40 cm)": 22.0,
|
||||
"Tenda AC10 V3.0 AC1200 Dual Band Gigabit Wireless Cable Router": 27.99,
|
||||
"KIOXIA EXERIA NVMe M.2 SSD (1TB)": 43.20,
|
||||
"Seagate BarraCuda,Internal Hard Drive 2.5 Inch (1TB)": 49.99,
|
||||
"Kingston NV2 NVMe PCIe 4.0 Internal SSD (1TB)": 59.99,
|
||||
"NVIDIA GeForce GTX 1650": 117.96,
|
||||
"AMD Radeon RX 5700 8GB (Second hand from CeX)": 150.00,
|
||||
"TP-Link AX5400 WiFi 6 Router": 159.99,
|
||||
"Intel Core i5-13400F": 191.99,
|
||||
"iPhone SE (3rd generation/5G/2022)": 499.00,
|
||||
"My custom PC (https://uk.pcpartpicker.com/user/nexy7574/saved/#view=cKYV4D)": 800.0,
|
||||
"NVIDIA GeForce RTX 4090": 1519.00,
|
||||
}
|
||||
|
||||
function compare(price, item) {
|
||||
let n = Math.floor((price / PRICES[item]) * 100) / 100;
|
||||
if(n>=1) {
|
||||
n = Math.floor(n);
|
||||
};
|
||||
return n
|
||||
}
|
||||
|
||||
function update(n, unit="$") {
|
||||
PRICE_ELEMENT.textContent = unit + n;
|
||||
console.debug("Updated price to: " + n);
|
||||
}
|
||||
|
||||
|
||||
function ids_to_body(ids) {
|
||||
return JSON.stringify({"app_ids": ids})
|
||||
}
|
||||
|
||||
function ratelimited(response) {
|
||||
let retry_after = response.headers.get("retry-after");
|
||||
console.warn("Rate-limited for " + retry_after + " seconds.");
|
||||
setTimeout(() => {window.location.reload()}, retry_after * 1000);
|
||||
setInterval(
|
||||
() => {
|
||||
let element = document.getElementById("retry-after");
|
||||
let value = parseFloat(element.textContent);
|
||||
value -= 0.1;
|
||||
element.textContent = value.toFixed(1);
|
||||
},
|
||||
100
|
||||
)
|
||||
|
||||
let element = document.getElementById("retrying");
|
||||
element.hidden = false;
|
||||
document.getElementById("retry-after").textContent = (retry_after * 1).toFixed(1);
|
||||
document.querySelector("main").hidden = true;
|
||||
document.querySelector("main").style.display = "none";
|
||||
}
|
||||
|
||||
|
||||
async function get_app_info(id) {
|
||||
const response = await fetch(BASE_URL, {method: "POST", body: ids_to_body([id]), headers: {"Content-Type": "application/json"}});
|
||||
if(response.status === 429) {
|
||||
ratelimited(response);
|
||||
return {id: {"name": "<rate limited>"}}
|
||||
}
|
||||
const data = await response.json();
|
||||
console.debug(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function calculate_price(ids) {
|
||||
console.debug("Calculating price for: " + ids);
|
||||
const response = await fetch(BASE_URL, {method: "POST", body: ids_to_body(ids), headers: {"Content-Type": "application/json"}});
|
||||
if(response.status === 429) {
|
||||
ratelimited(response);
|
||||
let o = {}
|
||||
for (let id of ids) {
|
||||
o[id] = {"price_overview": {"final": 0.0}}
|
||||
}
|
||||
return o
|
||||
}
|
||||
const data = await response.json();
|
||||
console.debug("mass price data:", data);
|
||||
|
||||
let _price = 0.0;
|
||||
let unit = "£";
|
||||
for (let value of Object.values(data)) {
|
||||
console.debug("Value:", value)
|
||||
if (value.price_overview == null) {
|
||||
continue;
|
||||
}
|
||||
let new_unit = value.price_overview.final_formatted.slice(0, 1);
|
||||
if((/\d/).test(new_unit)) {
|
||||
//new_unit = value.price_overview.final_formatted.slice(-2, -1);
|
||||
new_unit = value.price_overview.currency || '£'
|
||||
}
|
||||
unit = new_unit
|
||||
_price += parseFloat(value.price_overview.final / 100);
|
||||
}
|
||||
return [_price, unit];
|
||||
}
|
||||
|
||||
function find_unit(formatted) {
|
||||
let unit = "£";
|
||||
if((/\d/).test(formatted.slice(0, 1))) {
|
||||
unit = formatted.slice(-2, -1);
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.debug("Fetching app info for base game");
|
||||
let root;
|
||||
try {
|
||||
root = await get_app_info(TS4_APP_ID);
|
||||
} catch (e) {
|
||||
alert(`Error fetching information from Steam: ${e}`);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
console.debug("Fetching app info for DLCs");
|
||||
let key = Object.keys(root)[0];
|
||||
NAME_ELEMENT.textContent = root[key].name;
|
||||
console.debug("Checking key: " + key);
|
||||
let dlc_price = (root[key].price_overview || {final: 0.0}).final / 100;
|
||||
let unit = find_unit((root[key].price_overview || {final_formatted: "£0.00"}).final_formatted);
|
||||
if(root[key].dlc && root[key].dlc.length >= 0) {
|
||||
document.getElementById("dlcs").hidden = false;
|
||||
document.getElementById("dlc-count").textContent = root[key].dlc.length + " ";
|
||||
let _result= await calculate_price(root[key].dlc)
|
||||
dlc_price += _result[0];
|
||||
}
|
||||
|
||||
if(root[key].header_image && root[key].capsule_image) {
|
||||
let element2 = document.createElement("img");
|
||||
element2.src = root[key].header_image;
|
||||
element2.alt = root[key].name;
|
||||
element2.style.height = "2em"
|
||||
NAME_ELEMENT.textContent = null;
|
||||
NAME_ELEMENT.appendChild(element2);
|
||||
}
|
||||
|
||||
update(dlc_price.toFixed(2), unit);
|
||||
|
||||
let element = document.getElementById("comparison");
|
||||
element.hidden = false;
|
||||
for(let item of Object.keys(PRICES)) {
|
||||
let price = compare(dlc_price, item);
|
||||
if(price >= 0.95) {
|
||||
let li = document.createElement("li");
|
||||
li.textContent = `${price}x ${item} (${unit}${PRICES[item]}/pc)`;
|
||||
element.children[1].appendChild(li);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
main();
|
62
static/style.css
Executable file
62
static/style.css
Executable file
|
@ -0,0 +1,62 @@
|
|||
:root {
|
||||
--var-background: #fff;
|
||||
--var-color: #000;
|
||||
--var-background-inverted: #000
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--var-background: #000;
|
||||
--var-color: #fff;
|
||||
--var-background-inverted: #fff
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--var-background);
|
||||
color: var(--var-color);
|
||||
text-align: center;
|
||||
font-size: larger;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
html, body {
|
||||
font-size: medium;
|
||||
}
|
||||
}
|
||||
|
||||
h1, span img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#comparison {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#name {
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
#retrying[hidden=false] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
background: var(--var-background-inverted);
|
||||
color: inverted(var(--var-color));
|
||||
}
|
Loading…
Reference in a new issue