Updates to web page (add bootstrap), initial DB setup

This commit is contained in:
Flare Starfall 2026-03-06 10:57:30 +01:00
parent a9a418f301
commit fa8f24638e
24 changed files with 14331 additions and 133 deletions

2
.gitignore vendored
View File

@ -176,4 +176,4 @@ cython_debug/
# ---> StarfallBot
config.toml
web/static/style/*.scss
web/static/css/

13
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"basedpyright.analysis.diagnosticMode": "workspace",
"basedpyright.analysis.diagnosticSeverityOverrides": {
"reportAny": false,
"reportExplicitAny": false,
"reportMissingTypeStubs": false,
"reportUnknownMemberType": false,
"reportUnknownVariableType": false
},
"basedpyright.analysis.extraPaths": [
"starfallbot/.venv/lib/**/site-packages"
]
}

12
app.py
View File

@ -11,7 +11,7 @@ from starfall.log import Log
from starfall.types import SnapshotQueue
from starfall.web import WebUI
CURRENT_VERSION = "0.1.0-alpha.1"
CURRENT_VERSION = "0.1.0-alpha.2"
@final
@ -23,6 +23,7 @@ class Application:
threads: dict[str, Thread]
def __init__(self):
"""Initializes the application and prepares all threads for launch."""
self.log = Log()
logging.getLogger("app").debug(
@ -48,7 +49,11 @@ class Application:
self.app = WebUI()
def start(self):
def run(self):
"""
Starts all threads and monitors their active state.
This function runs indefinitely.
"""
self.threads["flask"] = Thread(
target=self.app.run,
args=(
@ -64,9 +69,10 @@ class Application:
self.monitor_queue()
def monitor_queue(self):
"""Monitors and logs all incoming messages in `self.queue`."""
for entry in self.queue.get_all_for_receiver("app"):
logging.getLogger("app").info("Received queue message: %r", entry)
if __name__ == "__main__":
Application().start()
Application().run()

View File

@ -1,5 +1,8 @@
discord-py-interactions
flask
flask_assets
libsass
livereload
flask-sqlalchemy
rich
twitchio

View File

@ -39,7 +39,7 @@ class Config:
error,
)
def get(self, key: str, default: Any = None):
def get(self, key: str, default: Any = None) -> Any:
"""Recursively calls `get(key, default)` to search nested dictionary.
Examples:

View File

@ -1,3 +1,30 @@
from flask_sqlalchemy import SQLAlchemy
import importlib
import logging
import os
from inspect import isclass
from pkgutil import iter_modules
from typing import final
db = SQLAlchemy()
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
@final
class BaseModel(DeclarativeBase):
pass
db = SQLAlchemy(model_class=BaseModel)
def load_schema():
path = os.path.realpath(os.path.dirname(__file__) + os.sep + "schema")
for _, module_name, _ in iter_modules([path], f"{__name__}.schema."):
logging.getLogger("db").debug("Parsing module: %s" % module_name)
module = importlib.import_module(module_name)
for attribute_name in dir(module):
attribute = getattr(module, attribute_name)
if isclass(attribute) and issubclass(attribute, db.Table):
globals()[attribute_name] = attribute

View File

@ -0,0 +1,10 @@
from sqlalchemy.orm import Mapped, mapped_column
from starfall.db import db
class Users(db.Table):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True)
username: Mapped[str] = mapped_column()
password: Mapped[str] = mapped_column()

View File

@ -6,7 +6,10 @@ from rich.logging import RichHandler
class Log:
def __init__(self) -> None:
handler = RichHandler(level=logging.DEBUG, rich_tracebacks=True)
handler: logging.Handler = RichHandler(
level=logging.DEBUG, rich_tracebacks=True
)
logging.basicConfig(
level=logging.DEBUG,
format="%(message)s [%(name)s]",

View File

@ -2,14 +2,15 @@ import importlib
import logging
import os
from inspect import isclass
from pathlib import Path
from pkgutil import iter_modules
from typing import final
from flask import Blueprint, Flask
from flask_assets import Bundle, Environment
from livereload import Server
from starfall.config import Config
from starfall.db import db
from starfall.db import db, load_schema
from starfall.types import SnapshotQueue
from starfall.web.blueprints.base import BaseBlueprint
@ -21,6 +22,8 @@ class WebUI:
self.queue: SnapshotQueue | None = None
self.app: Flask | None = None
self.blueprint: Blueprint = Blueprint("main", __name__)
self.assets: Environment | None = None
self.server: Server | None = None
def run(self, config: Config, queue: SnapshotQueue):
self.config = config
@ -28,20 +31,58 @@ class WebUI:
logging.getLogger("web").debug("Hello from %r", type(self))
self.app = Flask(__name__)
self.app = Flask(
import_name=__name__,
root_path=os.path.realpath("."),
static_folder=os.path.realpath("./web/static"),
template_folder="web/templates",
)
self.app.config.update(
SECRET_KEY=str(self.config.get("web.secret_key")),
SQLALCHEMY_DATABASE_URI=str(self.config.get("web.database_url")),
TEMPLATES_AUTO_RELOAD=True,
)
self.app.jinja_env.auto_reload = True
self.app.root_path = os.path.realpath(".")
self.app.static_folder = os.path.realpath("./web/static")
self.app.template_folder = "web"
self.assets = Environment(self.app)
self.assets.load_path = ["web/static/scss", "web/static/css"]
scss = Bundle(
"main.scss",
"fonts.scss",
"bootstrap.min.scss",
filters="libsass",
output="css/main.css",
depends=["**/*.scss"],
)
_ = self.assets.register("scss", scss)
db.init_app(self.app)
load_schema()
with self.app.app_context():
db.create_all()
self.import_blueprints()
self.app.register_blueprint(self.blueprint, options={"queue": self.queue})
self.app.run(host=self.config.get("web.host"), port=self.config.get("web.port"))
self.app.register_blueprint(
self.blueprint,
options={"queue": self.queue},
)
self.server = Server(self.app.wsgi_app)
self.server.watch(
filepath=os.path.join(str(self.app.template_folder), "**/*.html"),
)
self.server.watch(
filepath=os.path.join(str(self.app.static_folder), "**/*.js"),
)
self.server.watch(
filepath=os.path.join(str(self.app.static_folder), "**/*.scss"),
)
self.server.serve(
host=self.config.get("web.host"),
port=self.config.get("web.port"),
)
def import_blueprints(self):
path = os.path.realpath(os.path.dirname(__file__) + os.sep + "blueprints")
@ -49,6 +90,7 @@ class WebUI:
if module_name.endswith("base"):
continue
logging.getLogger("web").debug("Parsing module: %s" % module_name)
module = importlib.import_module(module_name)
for attribute_name in dir(module):
@ -56,4 +98,4 @@ class WebUI:
if isclass(attribute) and issubclass(attribute, BaseBlueprint):
globals()[attribute_name] = attribute
globals()[attribute_name](self.blueprint)
globals()[attribute_name](self.blueprint, self.assets)

View File

@ -1,30 +1,42 @@
import inspect
import logging
from abc import ABC
from types import FrameType
from flask import Blueprint
from flask.blueprints import BlueprintSetupState
from flask_assets import Environment
from starfall.types import SnapshotQueue
class BaseBlueprint(ABC):
def __init__(self, blueprint: Blueprint):
def __init__(self, blueprint: Blueprint, assets: Environment):
self.queue: SnapshotQueue | None = None
self.assets: Environment = assets
blueprint.record(self.on_blueprint_setup)
if type(self) != BaseBlueprint: # noqa: E721
if type(self) is not BaseBlueprint:
self._debug("Attaching blueprint of type %r to web service", type(self))
def on_blueprint_setup(self, state: BlueprintSetupState):
self.queue: SnapshotQueue = state.options["options"]["queue"]
self.queue = state.options["options"]["queue"]
if type(self) != BaseBlueprint: # noqa: E721
if type(self) is not BaseBlueprint:
self._info("Blueprint of type %r successfully set up", type(self))
def _log_access(self):
# There is always a previous frame - This function is called when a
# route is accessed by a user through a method unknown to this one.
self._info("Route access: %r", inspect.currentframe().f_back.f_code.co_name)
frame: FrameType | None = inspect.currentframe()
if not isinstance(frame, FrameType):
return
f_back: FrameType | None = frame.f_back
if not isinstance(f_back, FrameType):
return
self._info("Route access: %r", f_back.f_code.co_name)
def _debug(self, msg: str, *args: object):
logging.getLogger("web").debug(msg, *args)

View File

@ -1,13 +1,14 @@
from flask import Blueprint, render_template
from flask_assets import Environment
from starfall.web.blueprints.base import BaseBlueprint
class HomeBlueprint(BaseBlueprint):
def __init__(self, blueprint: Blueprint) -> None:
super().__init__(blueprint)
def __init__(self, blueprint: Blueprint, assets: Environment) -> None:
super().__init__(blueprint, assets)
blueprint.add_url_rule("/", view_func=self.index)
def index(self):
self._log_access()
return render_template("home.html")
return render_template("home.html", assets=self.assets, route="/")

View File

@ -1,11 +0,0 @@
{% extends "index.html" %}
{% block titleSuffix %}Overview{% endblock %}
{% block body %}
<div class="flex center">
<h1>StarfallBot - Overview</h1>
</div>
{% endblock %}

View File

@ -1,61 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>StarfallBot - {% block titleSuffix %}Unassigned{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="StarfallBot administation interface" />
<link rel="preload" href="{{ url_for('static', filename='img/logo.png') }}" as="image" />
<link rel="preload" href="{{ url_for('static', filename='style/fonts.css') }}" as="style" />
<link rel="preload" href="{{ url_for('static', filename='style/main.css') }}" as="style" />
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style/fonts.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style/main.css') }}" />
{% block head %}
{% endblock %}
</head>
<body>
{% block beforeMain %}
{% endblock %}
<header>
<div class="container">
<nav>
{% block beforeNav %}
{% endblock %}
<a href="/">Home</a>
{% block afterNav %}
{% endblock %}
</nav>
</div>
</header>
<main>
{% block beforeMainContainer %}
{% endblock %}
<div class="container">
{% block body %}
<p>No content has been defined for this page.</p>
{% endblock %}
</div>
{% block afterMainContainer %}
{% endblock %}
</main>
{% block afterMain %}
{% endblock %}
</body>
</html>

7
web/static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,80 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const themeSwitcherText = document.querySelector('#bd-theme-text')
const activeThemeIcon = document.querySelector('.theme-icon-active use')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('href', svgOfActiveBtn)
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()

BIN
web/static/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

13871
web/static/scss/bootstrap.min.scss vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
/* exo-2-regular - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 400;
src: url('../fonts/exo-2-v26-latin-regular.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* exo-2-700 - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 700;
src: url('../fonts/exo-2-v26-latin-700.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-regular - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 400;
src: url('../fonts/overpass-v19-latin-regular.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-700 - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 700;
src: url('../fonts/overpass-v19-latin-700.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

93
web/static/scss/main.scss Normal file
View File

@ -0,0 +1,93 @@
$siteHeadingFontFamily: 'Exo 2', sans-serif;
$siteFontFamily: 'Overpass', sans-serif;
$siteFontSize: 18px;
$mainBorderRadius: 16px;
$contentWidth: 1400px;
$contentPadding: 8px;
$fontSize: (
h1: 4rem,
h2: 2rem,
h3: 1.5rem,
h4: 1rem,
h5: 0.875rem,
h6: 0.75rem
);
[data-bs-theme="light"] {
--siteBackground: linear-gradient(to bottom right, rgb(136, 200, 255), rgb(135, 165, 255));
--siteTextColor: #000;
--mainBackground: #001030;
--mainTextColor: #fff;
}
[data-bs-theme="dark"] {
--siteBackground: linear-gradient(to bottom right, rgb(70, 81, 110), rgb(16, 20, 60));
--siteTextColor: #000;
--mainBackground: #001030;
--mainTextColor: #fff;
}
.navbar-brand img {
max-width: 40px;
transform: rotate(340deg);
}
body {
position: relative;
min-height: 100%;
color: var(--siteTextColor);
background: var(--siteBackground);
background: {
repeat: no-repeat;
attachment: fixed;
}
font: {
family: $siteFontFamily;
size: $siteFontSize;
}
}
// main {
// height: 100%;
// display: flex;
// justify-content: center;
// &>.container {
// width: 100%;
// height: 100%;
// max-width: $contentWidth;
// padding: $contentPadding;
// background: $mainBackground;
// border-radius: $mainBorderRadius;
// color: $mainTextColor;
// }
// .flex {
// display: flex;
// flex-flow: row wrap;
// &.center {
// justify-content: center;
// }
// }
// }
@each $tag, $size in $fontSize {
#{$tag} {
font: {
family: $siteHeadingFontFamily;
size: $size;
weight: bold;
}
padding: 0;
margin: 0;
}
}

View File

@ -1,35 +0,0 @@
/* exo-2-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 400;
src: url('../fonts/exo-2-v26-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* exo-2-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 700;
src: url('../fonts/exo-2-v26-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 400;
src: url('../fonts/overpass-v19-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 700;
src: url('../fonts/overpass-v19-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

View File

@ -1 +0,0 @@
body{position:relative;min-height:100%;color:#000;background-image:linear-gradient(to bottom, #205080, #4070a0);background-repeat:no-repeat;background-attachment:fixed;font-family:"Overpass",sans-serif;font-size:18px}main{height:100%;display:flex;justify-content:center}main>.container{width:100%;height:100%;max-width:1400px;padding:8px;background:#001030;border-radius:16px;color:#fff}main .flex{display:flex;flex-flow:row wrap}main .flex.center{justify-content:center}h1{font-family:"Exo 2",sans-serif;font-size:4rem;font-weight:bold;padding:0;margin:0}h2{font-family:"Exo 2",sans-serif;font-size:2rem;font-weight:bold;padding:0;margin:0}h3{font-family:"Exo 2",sans-serif;font-size:1.5rem;font-weight:bold;padding:0;margin:0}h4{font-family:"Exo 2",sans-serif;font-size:1rem;font-weight:bold;padding:0;margin:0}h5{font-family:"Exo 2",sans-serif;font-size:.875rem;font-weight:bold;padding:0;margin:0}h6{font-family:"Exo 2",sans-serif;font-size:.75rem;font-weight:bold;padding:0;margin:0}

View File

@ -0,0 +1,3 @@
<li class="nav-item">
<a class="nav-link {% if route == '/' %}active{% endif %}" href="/">Home</a>
</li>

5
web/templates/home.html Normal file
View File

@ -0,0 +1,5 @@
{% extends 'index.html' %}
{% block navLinks %}
{% include 'components/menu_public.html' %}
{% endblock %}

87
web/templates/index.html Normal file
View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<title>StarfallBot - {% block titleSuffix %}Unassigned{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="StarfallBot administation interface" />
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" />
<link rel="stylesheet" type="text/css" href="{{ assets['scss'].urls()|first }}" />
{% block head %}{% endblock %}
</head>
<body>
<a class="visually-hidden-focusable" href="#content">Skip to main content</a>
<header>
<nav class="navbar navbar-expand-md">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<img src="{{ url_for('static', filename='logo.png') }}" alt="Starfall" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% block navLinks %}{% endblock %}
<li class="nav-menu dropdown">
<button id="theme" class="btn btn-link nav-link dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">Theme</button>
<ul class="dropdown-menu">
<li>
<button class="dropdown-item" type="button" data-bs-theme-value="light">
Light
</button>
<button class="dropdown-item" type="button" data-bs-theme-value="dark">
Dark
</button>
<button class="dropdown-item" type="button" data-bs-theme-value="auto">
Auto
</button>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
{% block beforeMain %}{% endblock %}
<main>
{% block beforeMainContainer %}{% endblock %}
<div class="container" id="content">
{% block body %}
{# If there's no body on this page, treat it as a 404. #}
<div class="d-flex flex-column justify-content-center align-items-center">
<div>
<h1 class="text-danger-emphasis m-2">404</h1>
</div>
<div class="mb-4">
<h2 class="flex-col text-danger-emphasis">That's an error.</h2>
</div>
<div class="flex-row">
<p>Want to go back to the <a href="/">start</a> and try again?</p>
</div>
</div>
</div>
{% endblock %}
</div>
{% block afterMainContainer %}{% endblock %}
</main>
{% block afterMain %}{% endblock %}
<script type="text/javascript" src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/color-switcher.js') }}"></script>
</body>
</html>